mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
12
README.md
12
README.md
@@ -37,8 +37,8 @@ emaui/
|
||||
```
|
||||
|
||||
### libs/api
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or localStorage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads from `localStorage` keys or `auth-token` cookie. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
|
||||
|
||||
### libs/ui
|
||||
@@ -55,10 +55,10 @@ emaui/
|
||||
|
||||
1. User submits the login form (LoginForm / LoginPage).
|
||||
2. The form calls the `login` RTK Query mutation (backoffice) or a plain `fetch` (portal).
|
||||
3. On success, `loginSuccess` action is dispatched → Redux `auth` slice stores `token` and `user`; `authStorage.setToken()` persists the token to `localStorage`.
|
||||
3. On success, `loginSuccess` action is dispatched → Redux `auth` slice stores `token` and `user`; `authStorage.setToken()` persists the token to a cookie (both apps call `configureAuthStorage(prefix, true)`).
|
||||
4. `baseApi`'s `prepareHeaders` reads the token via `resolveSessionContext(getState())` and attaches `Authorization: Bearer <token>` to every RTK Query request.
|
||||
5. `ProtectedRoute` checks `localStorage` for the token key on every navigation — if absent, redirects to `/login`.
|
||||
6. `logout` action clears Redux state and calls `authStorage.clear()` to remove all localStorage keys.
|
||||
5. `ProtectedRoute` checks `authStorage`/the token cookie on every navigation — if absent, redirects to `/login`.
|
||||
6. `logout` action clears Redux state and calls `authStorage.clear()` to remove all auth cookies.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +90,8 @@ npm run dev:all
|
||||
|---|---|---|---|
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Analytics API - Coming soon
|
||||
// test from claude
|
||||
@@ -0,0 +1 @@
|
||||
// Analytics types - Coming soon
|
||||
@@ -39,12 +39,13 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
||||
|
||||
@@ -70,6 +71,7 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -226,7 +228,7 @@ export function CertificateDesignerPage() {
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name?.en ?? type.key,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
@@ -529,7 +531,7 @@ export function CertificateDesignerPage() {
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
@@ -550,7 +552,7 @@ export function CertificateDesignerPage() {
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
@@ -4,22 +4,19 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -55,27 +52,29 @@ function CertificationForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t('certification.update') : t('certification.add')} size="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t('certification.form.nameEn')} placeholder={t('certification.form.nameEnPlaceholder')} value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
const [deleteCert] = useDeleteCertificationMutation();
|
||||
@@ -104,8 +103,8 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,14 +115,49 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Certification>[] = [
|
||||
{
|
||||
header: t('certification.columns.name'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.description'),
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('certification.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.isActive ? 'teal' : 'gray'}>
|
||||
{row.original.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('certification.columns.actions', 'Actions'),
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(row.original); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(row.original); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(certifications);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -147,57 +181,28 @@ export function CertificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('certification.columns.name')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.description')}</Table.Th>
|
||||
<Table.Th>{t('certification.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{certifications.map((cert) => (
|
||||
<Table.Tr key={cert.id}>
|
||||
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(cert); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(cert); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{certifications.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('certification.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('certification.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('certification.noItems')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { baseApi } from "@ema-platform/api";
|
||||
import type {
|
||||
Organization,
|
||||
Profession,
|
||||
ListResponse,
|
||||
CreateProfessionPayload,
|
||||
UpdateProfessionPayload,
|
||||
} from '../types/configuration';
|
||||
} from "../types/configuration";
|
||||
|
||||
const configurationApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getOrganizations: builder.query<ListResponse<Organization>, void>({
|
||||
query: () => '/organizations',
|
||||
providesTags: ['Api'],
|
||||
query: () => "/organizations",
|
||||
providesTags: ["Api"],
|
||||
}),
|
||||
|
||||
getProfessions: builder.query<ListResponse<Profession>, void>({
|
||||
query: () => '/professions',
|
||||
providesTags: ['Api'],
|
||||
getProfessions: builder.query<ListResponse<Profession>, string>({
|
||||
query: (params) => ({
|
||||
url: `/professions?q=${encodeURIComponent(params)}`,
|
||||
}),
|
||||
providesTags: ["Api", "backOfficeApi", "ProfessionApi"],
|
||||
}),
|
||||
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
|
||||
query: (body) => ({ url: '/professions', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
query: (body) => ({ url: "/professions", method: "POST", body }),
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/professions/${id}`,
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
deleteProfession: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/professions/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }),
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: true,
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -7,31 +7,44 @@ import {
|
||||
Button,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Table,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
Select,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { LocationPage } from '../../location/pages/LocationPage';
|
||||
import { CertificationPage } from '../../certification/pages/CertificationPage';
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
IconBriefcase,
|
||||
IconMap,
|
||||
IconCertificate,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
notify,
|
||||
useErrorHandler,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
ModalFooter,
|
||||
type AdvancedColumn,
|
||||
} from "@ema-platform/ui";
|
||||
import { LocationPage } from "../../location/pages/LocationPage";
|
||||
import { CertificationPage } from "../../certification/pages/CertificationPage";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
useGetProfessionsQuery,
|
||||
useCreateProfessionMutation,
|
||||
useUpdateProfessionMutation,
|
||||
useDeleteProfessionMutation,
|
||||
} from '../api/configuration-api';
|
||||
import type { Profession } from '../types/configuration';
|
||||
} from "../api/configuration-api";
|
||||
import type { Profession } from "../types/configuration";
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -49,14 +62,27 @@ interface ProfFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
|
||||
function ProfessionForm({
|
||||
editingProf,
|
||||
deptOptions,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ProfFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<ProfFormValues>({
|
||||
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
|
||||
initialValues: {
|
||||
nameEn: "",
|
||||
nameAm: "",
|
||||
descEn: "",
|
||||
descAm: "",
|
||||
departmentId: "",
|
||||
},
|
||||
validate: {
|
||||
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
|
||||
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
|
||||
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
|
||||
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||
departmentId: (v) =>
|
||||
!v ? t("configuration.validation.departmentRequired") : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,90 +91,125 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa
|
||||
form.setValues({
|
||||
nameEn: editingProf.name.en,
|
||||
nameAm: editingProf.name.am,
|
||||
descEn: editingProf.description.en ?? '',
|
||||
descAm: editingProf.description.am ?? '',
|
||||
descEn: editingProf.description.en ?? "",
|
||||
descAm: editingProf.description.am ?? "",
|
||||
departmentId: editingProf.departmentId,
|
||||
});
|
||||
}
|
||||
}, [editingProf]);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
|
||||
const handleSubmit = form.onSubmit((values) =>
|
||||
onSubmit(values, !!editingProf),
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal
|
||||
opened
|
||||
onClose={onCancel}
|
||||
title={
|
||||
editingProf
|
||||
? t("configuration.update")
|
||||
: t("configuration.addProfession")
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('configuration.nameEn')}
|
||||
label={t("configuration.nameEn")}
|
||||
placeholder="English name"
|
||||
{...form.getInputProps('nameEn')}
|
||||
{...form.getInputProps("nameEn")}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameAm')}
|
||||
label={t("configuration.nameAm")}
|
||||
placeholder="የአማርኛ ስም"
|
||||
{...form.getInputProps('nameAm')}
|
||||
{...form.getInputProps("nameAm")}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descEn')}
|
||||
label={t("configuration.descEn")}
|
||||
placeholder="English description"
|
||||
{...form.getInputProps('descEn')}
|
||||
{...form.getInputProps("descEn")}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descAm')}
|
||||
label={t("configuration.descAm")}
|
||||
placeholder="የአማርኛ መግለጫ"
|
||||
{...form.getInputProps('descAm')}
|
||||
{...form.getInputProps("descAm")}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Select
|
||||
label={t('configuration.department')}
|
||||
placeholder={t('configuration.selectDepartment')}
|
||||
label={t("configuration.department")}
|
||||
placeholder={t("configuration.selectDepartment")}
|
||||
data={deptOptions}
|
||||
{...form.getInputProps('departmentId')}
|
||||
{...form.getInputProps("departmentId")}
|
||||
size="sm"
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t('configuration.cancel')}
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editingProf ? t('configuration.update') : t('configuration.create')}
|
||||
{editingProf
|
||||
? t("configuration.update")
|
||||
: t("configuration.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfessionTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: deptRes } = useGetOrganizationsQuery();
|
||||
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
|
||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
|
||||
const { pageIndex, setPageIndex, setQ, pageSize, setPageSize, skip, take } =
|
||||
useServerTable({
|
||||
pageSize: 10,
|
||||
});
|
||||
const {
|
||||
data: profRes,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useGetProfessionsQuery(
|
||||
`skip:${skip},take:${take},orderBy:createdAt:DESC`,
|
||||
);
|
||||
const [createProfession, { isLoading: isCreating }] =
|
||||
useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] =
|
||||
useUpdateProfessionMutation();
|
||||
const [deleteProfession] = useDeleteProfessionMutation();
|
||||
|
||||
const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []);
|
||||
// Server now paginates, so the previous client-side `isActive` filter is
|
||||
// dropped (it would hide rows outside just this page). Restore it via a
|
||||
// server-side `q` filter once the backend field name is confirmed.
|
||||
const professions = profRes?.items ?? [];
|
||||
const totalCount = profRes?.total ?? profRes?.count ?? professions.length;
|
||||
|
||||
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
||||
const [showProfForm, setShowProfForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const deptOptions = departments.filter((d) => d?.status?.toLowerCase() === 'active').map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name?.[locale] ?? d.name ?? '',
|
||||
}));
|
||||
const deptOptions = departments
|
||||
.filter((d) => d?.status?.toLowerCase() === "active")
|
||||
.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name?.[locale] ?? d.name ?? "",
|
||||
}));
|
||||
|
||||
const resetProfForm = useCallback(() => {
|
||||
setEditingProf(null);
|
||||
@@ -160,67 +221,136 @@ function ProfessionTab() {
|
||||
setShowProfForm(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProf = useCallback((prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
}, [openDelete]);
|
||||
const handleDeleteProf = useCallback(
|
||||
(prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
},
|
||||
[openDelete],
|
||||
);
|
||||
|
||||
const confirmDeleteProf = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteProfession(deleteTarget.id).unwrap();
|
||||
notify.success(t('configuration.deleted'));
|
||||
notify.success(t("configuration.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [deleteTarget, deleteProfession, closeDelete, t]);
|
||||
}, [deleteTarget, deleteProfession, closeDelete, handleError]);
|
||||
|
||||
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
const handleProfSubmit = useCallback(
|
||||
async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
|
||||
try {
|
||||
if (editingProf) {
|
||||
await updateProfession({
|
||||
id: editingProf.id,
|
||||
name,
|
||||
description,
|
||||
departmentId: values.departmentId,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.updated'));
|
||||
} else {
|
||||
await createProfession({
|
||||
departmentId: values.departmentId,
|
||||
name,
|
||||
description,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.created'));
|
||||
try {
|
||||
if (editingProf) {
|
||||
await updateProfession({
|
||||
id: editingProf.id,
|
||||
name,
|
||||
description,
|
||||
departmentId: values.departmentId,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.updated"));
|
||||
} else {
|
||||
await createProfession({
|
||||
departmentId: values.departmentId,
|
||||
name,
|
||||
description,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.created"));
|
||||
}
|
||||
resetProfForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
resetProfForm();
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
}
|
||||
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
|
||||
},
|
||||
[
|
||||
editingProf,
|
||||
createProfession,
|
||||
updateProfession,
|
||||
resetProfForm,
|
||||
handleError,
|
||||
],
|
||||
);
|
||||
|
||||
const getDeptName = useCallback((deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
|
||||
}, [departments, locale]);
|
||||
const getDeptName = useCallback(
|
||||
(deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? "-") : "-";
|
||||
},
|
||||
[departments, locale],
|
||||
);
|
||||
|
||||
const professionColumns: AdvancedColumn<Profession>[] = [
|
||||
{
|
||||
header: t("configuration.name"),
|
||||
cell: ({ row }) => row.original.name[locale],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
header: t("configuration.description"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" lineClamp={2} maw={200}>
|
||||
{row.original.description[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("configuration.department"),
|
||||
cell: ({ row }) => getDeptName(row.original.departmentId),
|
||||
},
|
||||
{
|
||||
header: "actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handleEditProf(row.original)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handleDeleteProf(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <Center py="xl"><Loader /></Center>;
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('configuration.error')} />;
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("configuration.error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="flex-end" mb="md">
|
||||
<Title order={2}>{t('configuration.professionsList')}</Title>
|
||||
<Title order={2}>{t("configuration.professionsList")}</Title>
|
||||
{!showProfForm && (
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -228,7 +358,7 @@ function ProfessionTab() {
|
||||
onClick={() => setShowProfForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t('configuration.addProfession')}
|
||||
{t("configuration.addProfession")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -243,55 +373,40 @@ function ProfessionTab() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('configuration.name')}</Table.Th>
|
||||
<Table.Th>{t('configuration.description')}</Table.Th>
|
||||
<Table.Th>{t('configuration.department')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{professions.filter((p) => p.isActive).map((prof) => (
|
||||
<Table.Tr key={prof.id}>
|
||||
<Table.Td>{prof.name[locale]}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{getDeptName(prof.departmentId)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handleEditProf(prof)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handleDeleteProf(prof)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{professions.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={4}>
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
{t('configuration.noProfessions')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
<AdvancedTable
|
||||
columns={professionColumns}
|
||||
data={professions}
|
||||
tableName={t("configuration.professionsList")}
|
||||
itemCount={totalCount}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
onSearchChange={setQ}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("configuration.noProfessions")}
|
||||
/>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("configuration.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.[locale] ?? '' })}
|
||||
{t("configuration.deleteConfirmText", {
|
||||
name: deleteTarget?.name?.[locale] ?? "",
|
||||
})}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDeleteProf} size="sm">{t('configuration.delete')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDeleteProf} size="sm">
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
@@ -302,18 +417,24 @@ export function ConfigurationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t('configuration.title')}</Title>
|
||||
<Title order={2}>{t("configuration.title")}</Title>
|
||||
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
|
||||
{t('configuration.professions')}
|
||||
<Tabs.Tab
|
||||
value="professions"
|
||||
leftSection={<IconBriefcase size={16} />}
|
||||
>
|
||||
{t("configuration.professions")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||
{t('location.title')}
|
||||
{t("location.title")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
|
||||
{t('certification.title')}
|
||||
<Tabs.Tab
|
||||
value="certifications"
|
||||
leftSection={<IconCertificate size={16} />}
|
||||
>
|
||||
{t("certification.title")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ export interface Profession {
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
count?: number;
|
||||
total?: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function EndorsementQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsement queue"
|
||||
description="Endorsement processing is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementQueuePage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function EndorsementReviewPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsement review"
|
||||
description="Endorsement processing is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default EndorsementReviewPage;
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
@@ -51,6 +52,7 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
@@ -175,7 +177,7 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>
|
||||
<Text fz="xs">{showDate(incident.occurredAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Group,
|
||||
@@ -10,16 +10,17 @@ import {
|
||||
Checkbox,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import type { QuestionBrief } from '../types/exam';
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import type { QuestionBrief, actionTypes } from "../types/exam";
|
||||
|
||||
interface QuestionAssignerProps {
|
||||
available: QuestionBrief[];
|
||||
assigned: QuestionBrief[];
|
||||
onChange: (assigned: QuestionBrief[]) => void;
|
||||
mode?: 'manual' | 'random';
|
||||
mode?: "manual" | "random";
|
||||
actions: Dispatch<SetStateAction<actionTypes | undefined>>;
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
@@ -38,11 +39,13 @@ function QuestionList({
|
||||
label: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const placeholder = t('exam.assigner.search');
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const placeholder = t("exam.assigner.search");
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>
|
||||
{label} ({items.length})
|
||||
</Text>
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="sm" pb={0}>
|
||||
<TextInput
|
||||
@@ -57,7 +60,9 @@ function QuestionList({
|
||||
<ScrollArea h={280} p="sm" pt="xs">
|
||||
<Stack gap={4}>
|
||||
{items.length === 0 && (
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">
|
||||
{t("exam.assigner.noQuestions")}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((q) => (
|
||||
<Paper
|
||||
@@ -66,19 +71,37 @@ function QuestionList({
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
cursor: "pointer",
|
||||
borderColor: selected.has(q.id)
|
||||
? "var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
background: selected.has(q.id)
|
||||
? "var(--mantine-color-blue-0)"
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => onToggle(q.id)}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
|
||||
<Checkbox
|
||||
checked={selected.has(q.id)}
|
||||
onChange={() => onToggle(q.id)}
|
||||
size="xs"
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" lineClamp={2}>{q.title[locale]}</Text>
|
||||
<Text fz="xs" lineClamp={2}>
|
||||
{q.title[locale]}
|
||||
</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={q.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{q.form}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{q.points} pts
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -91,49 +114,71 @@ function QuestionList({
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
|
||||
export function QuestionAssigner({
|
||||
available,
|
||||
assigned,
|
||||
onChange,
|
||||
mode = "manual",
|
||||
actions,
|
||||
}: QuestionAssignerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchLeft, setSearchLeft] = useState('');
|
||||
const [searchRight, setSearchRight] = useState('');
|
||||
const [searchLeft, setSearchLeft] = useState("");
|
||||
const [searchRight, setSearchRight] = useState("");
|
||||
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
||||
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
|
||||
const assignedIds = new Set(assigned.map((q) => q.id));
|
||||
|
||||
const filteredAvailable = available.filter(
|
||||
(q) => !assignedIds.has(q.id) && (q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) || q.title.am.includes(searchLeft))
|
||||
(q) =>
|
||||
!assignedIds.has(q.id) &&
|
||||
(q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) ||
|
||||
q.title.am.includes(searchLeft)),
|
||||
);
|
||||
const filteredAssigned = assigned.filter(
|
||||
(q) => q.title.en.toLowerCase().includes(searchRight.toLowerCase()) || q.title.am.includes(searchRight)
|
||||
(q) =>
|
||||
q.title.en.toLowerCase().includes(searchRight.toLowerCase()) ||
|
||||
q.title.am.includes(searchRight),
|
||||
);
|
||||
|
||||
const assignSelected = () => {
|
||||
const toAssign = available.filter((q) => selectedLeft.has(q.id));
|
||||
onChange([...assigned, ...toAssign]);
|
||||
actions("add");
|
||||
setSelectedLeft(new Set());
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
||||
actions("remove");
|
||||
setSelectedRight(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
|
||||
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
|
||||
{mode === "manual" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.title")}
|
||||
</Text>
|
||||
)}
|
||||
{mode === "random" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.assignedTitle")}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<QuestionList
|
||||
items={filteredAvailable}
|
||||
selected={selectedLeft}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedLeft);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedLeft(next);
|
||||
}}
|
||||
search={searchLeft}
|
||||
onSearchChange={setSearchLeft}
|
||||
label={t('exam.assigner.available')}
|
||||
label={t("exam.assigner.available")}
|
||||
/>
|
||||
)}
|
||||
<QuestionList
|
||||
@@ -141,32 +186,43 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
|
||||
selected={selectedRight}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedRight);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedRight(next);
|
||||
}}
|
||||
search={searchRight}
|
||||
onSearchChange={setSearchRight}
|
||||
label={t('exam.assigner.assigned')}
|
||||
label={t("exam.assigner.assigned")}
|
||||
/>
|
||||
</Group>
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<Group gap="sm" justify="center">
|
||||
{selectedLeft.size > 0 && (
|
||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
|
||||
{t("exam.assigner.assignSelected", { count: selectedLeft.size })}
|
||||
</Button>
|
||||
)}
|
||||
{selectedRight.size > 0 && (
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{mode === 'random' && selectedRight.size > 0 && (
|
||||
{mode === "random" && selectedRight.size > 0 && (
|
||||
<Group gap="sm" justify="center">
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ThemeIcon,
|
||||
Box,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconPrinter,
|
||||
@@ -56,32 +56,50 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
|
||||
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
|
||||
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
|
||||
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||
const ADMIN_LABEL: Record<string, string> = {
|
||||
OFFLINE: "Offline",
|
||||
ONLINE: "Online",
|
||||
};
|
||||
const EVAL_LABEL: Record<string, string> = {
|
||||
SUM: "Sum",
|
||||
AVERAGE: "Average",
|
||||
PERCENTAGE: "Percentage",
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamDetailPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] =
|
||||
useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||
useDisclosure(false);
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
@@ -109,12 +127,26 @@ export function ExamDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
if (isError || !exam) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowLeft size={15} />}
|
||||
w="fit-content"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
{t("exam.backToExams")}
|
||||
</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>
|
||||
{t("exam.notFound")}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -161,42 +193,51 @@ export function ExamDetailPage() {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
|
||||
notify.error(
|
||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
let logoBase64 = '';
|
||||
let logoBase64 = "";
|
||||
try {
|
||||
const resp = await fetch('/ema-logo.png');
|
||||
const resp = await fetch("/ema-logo.png");
|
||||
const blob = await resp.blob();
|
||||
logoBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch { /* logo not available */ }
|
||||
} catch {
|
||||
/* logo not available */
|
||||
}
|
||||
|
||||
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
|
||||
const qHtml = (exam.questions ?? []).map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr = full?.description?.[locale] || full?.description?.en || '';
|
||||
return `
|
||||
const qHtml = (exam.questions ?? [])
|
||||
.map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr =
|
||||
full?.description?.[locale] || full?.description?.en || "";
|
||||
return `
|
||||
<div style="margin-bottom: 24px; page-break-inside: avoid;">
|
||||
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
|
||||
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ''}
|
||||
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
|
||||
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
||||
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
||||
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
printWindow.document.write(`
|
||||
<html><head><title>${exam.title[locale] || exam.title.en}</title>
|
||||
@@ -211,13 +252,13 @@ export function ExamDetailPage() {
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
<h1>${exam.title[locale] || exam.title.en}</h1>
|
||||
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
|
||||
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
|
||||
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : "N/A"}</p>
|
||||
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
|
||||
</div>
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
||||
${qHtml}
|
||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
@@ -229,15 +270,25 @@ export function ExamDetailPage() {
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
|
||||
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
|
||||
const totalPoints = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
const certName =
|
||||
exam.certification?.name?.[locale] ??
|
||||
certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ??
|
||||
"—";
|
||||
|
||||
return (
|
||||
<Stack gap="md" ref={printRef}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
@@ -245,41 +296,93 @@ export function ExamDetailPage() {
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
|
||||
{t('exam.print')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPrinter size={15} />}
|
||||
onClick={handlePrint}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.print")}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
|
||||
{t('exam.recordResult')}
|
||||
<Button
|
||||
leftSection={<IconPlus size={15} />}
|
||||
onClick={openRecord}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.recordResult")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[exam.status]}
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
|
||||
{/* Exam Info */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">{t('exam.detail.title')}</Title>
|
||||
<Title order={5} mb="md">
|
||||
{t("exam.detail.title")}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label={t('exam.detail.certification')} value={certName} />
|
||||
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
|
||||
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
|
||||
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
|
||||
<InfoRow label={t('exam.detail.date')} value={exam.date} />
|
||||
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
|
||||
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
|
||||
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
|
||||
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
|
||||
<InfoRow label={t("exam.detail.certification")} value={certName} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.type")}
|
||||
value={t(`exam.type.${exam.type}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.form")}
|
||||
value={t(`exam.formType.${exam.form}`)}
|
||||
/>
|
||||
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
|
||||
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={t(`exam.admin.${exam.administrationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.evaluation")}
|
||||
value={t(`exam.eval.${exam.evaluationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.selection")}
|
||||
value={t(`exam.selection.${exam.selectionMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
value={
|
||||
exam.givenTime
|
||||
? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.passMark")}
|
||||
value={String(exam.cuttingPoint)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.totalPoints")}
|
||||
value={String(totalPoints)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.questions")}
|
||||
value={String((exam.questions ?? []).length)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{(exam.direction?.en || exam.direction?.am) && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.directions")}
|
||||
value={[exam.direction?.en, exam.direction?.am]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -287,24 +390,41 @@ export function ExamDetailPage() {
|
||||
{/* Questions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
|
||||
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
|
||||
{t('exam.manageQuestions')}
|
||||
<Title order={5}>
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.noQuestionsAssigned')}
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(exam.questions ?? []).map((q, i) => (
|
||||
<Paper key={q.id} withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fz="sm" fw={700}>{t('exam.detail.questionLabel')} {i + 1}</Text>
|
||||
<Text fz="sm" fw={700}>
|
||||
{t("exam.detail.questionLabel")} {i + 1}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={q.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{t(`exam.formType.${q.form}`)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{q.points} pts
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="sm">{q.title[locale]}</Text>
|
||||
@@ -321,27 +441,38 @@ export function ExamDetailPage() {
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')} — ${exam.title[locale]}`} size="xl" radius="lg">
|
||||
<Modal
|
||||
opened={assignOpened}
|
||||
onClose={closeAssign}
|
||||
title={`${t("exam.manageQuestions")} — ${exam.title[locale]}`}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{exam.selectionMethod === 'MANUAL' ? (
|
||||
{exam.selectionMethod === "MANUAL" ? (
|
||||
<>
|
||||
<QuestionAssigner
|
||||
available={eligibleQuestions}
|
||||
assigned={draftQuestions}
|
||||
onChange={setDraftQuestions}
|
||||
mode="manual"
|
||||
actions={setWhatAction}
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder={t('exam.assigner.selectCount')}
|
||||
placeholder={t("exam.assigner.selectCount")}
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
@@ -358,10 +489,14 @@ export function ExamDetailPage() {
|
||||
onChange={setDraftQuestions}
|
||||
mode="random"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,47 +1,51 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconClipboardList,
|
||||
IconDetails,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker, type AdvancedColumn } from "@ema-platform/ui";
|
||||
import { useGetCertificationsQuery } from "../../certification/api/certification-api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
} from '../api/exam-api';
|
||||
import type { Exam } from '../types/exam';
|
||||
} from "../api/exam-api";
|
||||
import type { Exam } from "../types/exam";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
ACTIVE: 'blue',
|
||||
COMPLETED: 'teal',
|
||||
CANCELLED: 'red',
|
||||
POSTPONED: 'orange',
|
||||
PUBLISHED: 'green',
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
};
|
||||
|
||||
function ExamForm({
|
||||
@@ -58,100 +62,297 @@ function ExamForm({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
|
||||
const [date, setDate] = useState(editing?.date ?? '');
|
||||
const [certificationId, setCertificationId] = useState<string | null>(
|
||||
editing?.certificationId ?? null,
|
||||
);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? "");
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? "");
|
||||
const [date, setDate] = useState(editing?.date ?? "");
|
||||
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||
const [type, setType] = useState<string | null>(editing?.type ?? null);
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [venue, setVenue] = useState(editing?.venue ?? '');
|
||||
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
|
||||
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
|
||||
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
|
||||
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
|
||||
const [venue, setVenue] = useState(editing?.venue ?? "");
|
||||
const [adminMethod, setAdminMethod] = useState<string | null>(
|
||||
editing?.administrationMethod ?? null,
|
||||
);
|
||||
const [evalMethod, setEvalMethod] = useState<string | null>(
|
||||
editing?.evaluationMethod ?? null,
|
||||
);
|
||||
const [selMethod, setSelMethod] = useState<string | null>(
|
||||
editing?.selectionMethod ?? null,
|
||||
);
|
||||
const [cuttingPoint, setCuttingPoint] = useState<number>(
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
|
||||
notify.error('Please fill all required fields');
|
||||
if (
|
||||
!certificationId ||
|
||||
!titleEn ||
|
||||
!titleAm ||
|
||||
!date ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
certificationId, titleEn, titleAm, directionEn, directionAm,
|
||||
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
|
||||
}, !!editing);
|
||||
onSubmit(
|
||||
{
|
||||
certificationId,
|
||||
titleEn,
|
||||
titleAm,
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
type,
|
||||
form,
|
||||
venue,
|
||||
adminMethod,
|
||||
evalMethod,
|
||||
selMethod,
|
||||
cuttingPoint,
|
||||
status,
|
||||
},
|
||||
!!editing,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
|
||||
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="settings"
|
||||
leftSection={<IconClipboardList size={15} />}
|
||||
>
|
||||
{t("exam.form.settings")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
|
||||
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("exam.form.certification")}
|
||||
placeholder={t("exam.form.selectCertification")}
|
||||
data={certOptions}
|
||||
value={certificationId}
|
||||
onChange={setCertificationId}
|
||||
size="sm"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleEn")}
|
||||
placeholder={t("exam.form.titleEnPlaceholder")}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleAm")}
|
||||
placeholder={t("exam.form.titleAmPlaceholder")}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionEn")}
|
||||
placeholder={t("exam.form.directionEnPlaceholder")}
|
||||
value={directionEn}
|
||||
onChange={(e) => setDirectionEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionAm")}
|
||||
placeholder={t("exam.form.directionAmPlaceholder")}
|
||||
value={directionAm}
|
||||
onChange={(e) => setDirectionAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t("exam.form.examDate")}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
dateFormat="date"
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.venue")}
|
||||
placeholder={t("exam.form.venuePlaceholder")}
|
||||
value={venue}
|
||||
onChange={(e) => setVenue(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.form.timeAllowed")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label={t("exam.form.days")}
|
||||
value={days}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.hours")}
|
||||
value={hours}
|
||||
onChange={(v) => setHours(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.minutes")}
|
||||
value={minutes}
|
||||
onChange={(v) => setMinutes(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select label={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
|
||||
<Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
||||
<Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
|
||||
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
|
||||
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
|
||||
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
|
||||
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
|
||||
]} value={status} onChange={setStatus} size="sm" />
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label={t("exam.columns.type")}
|
||||
placeholder="Written or Oral"
|
||||
data={[
|
||||
{ value: "WRITTEN", label: t("exam.form.written") },
|
||||
{ value: "ORAL", label: t("exam.form.oral") },
|
||||
]}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.columns.form")}
|
||||
placeholder="Essay or Choice"
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
placeholder="Offline or Online"
|
||||
data={[
|
||||
{ value: "OFFLINE", label: t("exam.form.offline") },
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.evaluation")}
|
||||
placeholder="How to compute score"
|
||||
data={[
|
||||
{ value: "SUM", label: t("exam.form.sum") },
|
||||
{ value: "AVERAGE", label: t("exam.form.average") },
|
||||
{ value: "PERCENTAGE", label: t("exam.form.percentage") },
|
||||
]}
|
||||
value={evalMethod}
|
||||
onChange={setEvalMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.selection")}
|
||||
placeholder="Manual or Random"
|
||||
data={[
|
||||
{ value: "MANUAL", label: t("exam.form.manual") },
|
||||
{ value: "RANDOM", label: t("exam.form.random") },
|
||||
]}
|
||||
value={selMethod}
|
||||
onChange={setSelMethod}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select
|
||||
label={t("exam.form.status")}
|
||||
placeholder={t("exam.form.statusPlaceholder")}
|
||||
data={[
|
||||
{ value: "PENDING", label: t("exam.form.pending") },
|
||||
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
|
||||
</Group>
|
||||
<ModalFooter mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetExamsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
@@ -162,26 +363,40 @@ export function ExamPage() {
|
||||
const [editing, setEditing] = useState<Exam | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any, isEdit: boolean) => {
|
||||
const payload: any = {
|
||||
certificationId: values.certificationId,
|
||||
title: { en: values.titleEn, am: values.titleAm },
|
||||
direction: values.directionEn || values.directionAm ? { en: values.directionEn, am: values.directionAm } : undefined,
|
||||
direction:
|
||||
values.directionEn || values.directionAm
|
||||
? { en: values.directionEn, am: values.directionAm }
|
||||
: undefined,
|
||||
date: values.date,
|
||||
givenTime: { days: values.days, hours: values.hours, minutes: values.minutes },
|
||||
givenTime: {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
minutes: values.minutes,
|
||||
},
|
||||
type: values.type,
|
||||
form: values.form,
|
||||
venue: values.venue,
|
||||
administrationMethod: values.adminMethod,
|
||||
evaluationMethod: values.evalMethod,
|
||||
selectionMethod: values.selMethod || 'MANUAL',
|
||||
selectionMethod: values.selMethod || "MANUAL",
|
||||
cuttingPoint: values.cuttingPoint,
|
||||
};
|
||||
if (isEdit) payload.status = values.status;
|
||||
@@ -189,14 +404,14 @@ export function ExamPage() {
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateExam({ id: editing.id, ...payload }).unwrap();
|
||||
notify.success(t('exam.updated'));
|
||||
notify.success(t("exam.updated"));
|
||||
} else {
|
||||
await createExam(payload).unwrap();
|
||||
notify.success(t('exam.created'));
|
||||
notify.success(t("exam.created"));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -204,27 +419,143 @@ export function ExamPage() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteExam(deleteTarget.id).unwrap();
|
||||
notify.success(t('exam.deleted'));
|
||||
notify.success(t("exam.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
|
||||
const columns: AdvancedColumn<Exam>[] = [
|
||||
{
|
||||
header: t("exam.columns.title"),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => navigate(`/exams/${row.original.id}`)}
|
||||
>
|
||||
{row.original.title[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.certification"),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.type"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.type === "WRITTEN" ? "blue" : "orange"}>
|
||||
{t(`exam.type.${row.original.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.form"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === "ESSAY" ? "blue" : "violet"}>
|
||||
{t(`exam.formType.${row.original.form}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.venue"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.questions"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{row.original.questions?.length ?? 0}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
|
||||
{t(`exam.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("exam.columns.actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setEditing(row.original);
|
||||
setShowForm(true);
|
||||
}}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setDeleteTarget(row.original);
|
||||
openDelete();
|
||||
}}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
navigate(`/exams/${row.original.id}`);
|
||||
}}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(exams);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('exam.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('exam.add')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -239,70 +570,42 @@ export function ExamPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('exam.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.date')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.type')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.venue')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.questions')}</Table.Th>
|
||||
<Table.Th>{t('exam.columns.status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{exams.map((exam) => (
|
||||
<Table.Tr key={exam.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500} c="blue" style={{ cursor: 'pointer' }} onClick={() => navigate(`/exams/${exam.id}`)}>
|
||||
{exam.title[locale]}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(exam.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.date}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>{t(`exam.type.${exam.type}`)}</Badge></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${exam.form}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm">{exam.venue}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color="gray">{exam.questions?.length ?? 0}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>{t(`exam.status.${exam.status}`)}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(exam); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(exam); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{exams.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={9}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('exam.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t("exam.title")}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("exam.noItems")}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
|
||||
</Group>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("exam.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
{t("exam.deleteConfirmText", {
|
||||
name: deleteTarget?.title?.[locale] ?? "",
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("exam.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { LocalePair } from '../../certification/types/certification';
|
||||
import type { EstimatedTime } from '../../question/types/question';
|
||||
import type { QuestionForm } from '../../question/types/question';
|
||||
import type { LocalePair } from "../../certification/types/certification";
|
||||
import type { EstimatedTime } from "../../question/types/question";
|
||||
import type { QuestionForm } from "../../question/types/question";
|
||||
export type { QuestionForm };
|
||||
|
||||
export type ExamType = 'WRITTEN' | 'ORAL';
|
||||
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
|
||||
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
|
||||
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
|
||||
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
|
||||
export type ExamType = "WRITTEN" | "ORAL";
|
||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||
export type ExamStatus =
|
||||
| "PENDING"
|
||||
| "ACTIVE"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED"
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -12,13 +13,15 @@ const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
export function ItemTable() {
|
||||
const { data, isLoading } = useGetItemsQuery({});
|
||||
const [deleteItem] = useDeleteItemMutation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteItem(id).unwrap();
|
||||
notify.success('Item deleted');
|
||||
} catch {
|
||||
notify.error('Failed to delete item');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -42,7 +45,7 @@ export function ItemTable() {
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>{new Date(item.createdAt).toLocaleDateString()}</Table.Td>
|
||||
<Table.Td>{showDate(item.createdAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
|
||||
@@ -19,9 +19,10 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicensesQuery,
|
||||
useReinstateLicenseMutation,
|
||||
useRevokeLicenseMutation,
|
||||
@@ -166,6 +167,8 @@ export function LicenseRegisterPage() {
|
||||
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -227,12 +230,12 @@ export function LicenseRegisterPage() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.issueDate?.slice(0, 10)}
|
||||
{showDate(license.issueDate)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.expiryDate?.slice(0, 10)}
|
||||
{showDate(license.expiryDate)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
type ApplicationDetail,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
||||
|
||||
@@ -50,7 +51,8 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
* notifications sent to the applicant are not among them.
|
||||
*/
|
||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const entries = useMemo<ActivityEntry[]>(() => {
|
||||
const merged: ActivityEntry[] = [];
|
||||
@@ -69,9 +71,11 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
? t(`review.events.${history.event}`, {
|
||||
defaultValue: history.event,
|
||||
})
|
||||
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'} → ${
|
||||
STATUS_LABELS[history.toStatus]
|
||||
}`,
|
||||
: `${
|
||||
history.fromStatus
|
||||
? t(`queue.statusValues.${history.fromStatus}`, STATUS_LABELS[history.fromStatus])
|
||||
: '—'
|
||||
} → ${t(`queue.statusValues.${history.toStatus}`, STATUS_LABELS[history.toStatus])}`,
|
||||
detail: history.remark ?? undefined,
|
||||
color: STATUS_COLORS[history.toStatus],
|
||||
});
|
||||
@@ -159,12 +163,9 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={new Date(entry.at).toLocaleString(i18n.language)}
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={showDate(entry.at)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.at).toLocaleDateString(i18n.language)}
|
||||
{showDate(entry.at.slice(0, 10))}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DecisionBar({
|
||||
{/* Left: where the application stands, and who has it. */}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||
{STATUS_LABELS[status]}
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
|
||||
{assigneeName && (
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import type { ResolvedAction } from '../config/actions';
|
||||
|
||||
/** Reason codes offered per action. Free text is always available too. */
|
||||
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useClearDocumentReviewMutation,
|
||||
useGetDocumentReviewsQuery,
|
||||
useLocalized,
|
||||
useReviewDocumentMutation,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
@@ -62,6 +63,7 @@ export function DocumentsTab({
|
||||
onFlagRemark,
|
||||
}: DocumentsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -114,6 +116,7 @@ export function DocumentsTab({
|
||||
}
|
||||
|
||||
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
||||
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
|
||||
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
|
||||
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
||||
const completeness = mandatory.length
|
||||
@@ -148,7 +151,7 @@ export function DocumentsTab({
|
||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||
<Text size="sm">
|
||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
||||
{missing.map((r) => r.name.en ?? r.key).join(', ')}
|
||||
{missing.map((r) => localized(r.name) || r.key).join(', ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -162,49 +165,56 @@ export function DocumentsTab({
|
||||
return (
|
||||
<Paper withBorder p="md" key={attachment.id}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
<IconFileText size={20} stroke={1.6} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{attachment.documentKey}
|
||||
</Text>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
{/* Badges sit beside the name, not in the outer nowrap row —
|
||||
that row also has to fit six action buttons, so a long
|
||||
name plus badges there overflowed its box and the
|
||||
opaque badge painted over the bleeding text. Nested here
|
||||
with its own wrap, the name truncates cleanly instead. */}
|
||||
<Group gap={6} wrap="wrap" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
|
||||
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
|
||||
</Text>
|
||||
{verdict && (
|
||||
<Tooltip
|
||||
label={
|
||||
verdict.reason ??
|
||||
t('review.documents.reviewedBy', {
|
||||
name: verdict.reviewedByName ?? '—',
|
||||
defaultValue: 'Reviewed by {{name}}',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{flagged && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.documents.flagged', 'Correction requested')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
||||
</Text>
|
||||
</div>
|
||||
{verdict && (
|
||||
<Tooltip
|
||||
label={
|
||||
verdict.reason ??
|
||||
t('review.documents.reviewedBy', {
|
||||
name: verdict.reviewedByName ?? '—',
|
||||
defaultValue: 'Reviewed by {{name}}',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{flagged && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.documents.flagged', 'Correction requested')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
@@ -387,7 +397,11 @@ export function DocumentsTab({
|
||||
onClose={() => setPreview(null)}
|
||||
position="right"
|
||||
size="xl"
|
||||
title={preview?.documentKey}
|
||||
title={
|
||||
preview
|
||||
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
|
||||
: ''
|
||||
}
|
||||
// Focus is trapped and returned so keyboard users are not dropped at
|
||||
// the top of the page when the drawer closes.
|
||||
trapFocus
|
||||
@@ -397,13 +411,13 @@ export function DocumentsTab({
|
||||
isPdf ? (
|
||||
<iframe
|
||||
src={previewFile.url}
|
||||
title={preview?.documentKey ?? 'document'}
|
||||
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={previewFile.url}
|
||||
alt={preview?.documentKey ?? 'document'}
|
||||
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconRubberStamp,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
@@ -77,6 +80,36 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
icon: IconAnchor,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
SEAFARER_REGISTRATION: {
|
||||
key: 'SEAFARER_REGISTRATION',
|
||||
icon: IconId,
|
||||
// Person-centric: no company entity, no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
VESSEL_REGISTRATION: {
|
||||
key: 'VESSEL_REGISTRATION',
|
||||
icon: IconAnchor,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
VESSEL_OWNERSHIP_TRANSFER: {
|
||||
key: 'VESSEL_OWNERSHIP_TRANSFER',
|
||||
icon: IconArrowsExchange,
|
||||
// Transfers an existing registration between owners: no company entity,
|
||||
// no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_COC: {
|
||||
key: 'ENDORSEMENT_COC',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_GOC: {
|
||||
key: 'ENDORSEMENT_GOC',
|
||||
icon: IconRubberStamp,
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
@@ -111,10 +144,15 @@ export interface EligibilityRule {
|
||||
* pass/fail line means the rule, the figure it was checked against, and the
|
||||
* outcome are all on screen.
|
||||
*/
|
||||
/** Loose enough to accept i18next's real `t` structurally — see sla.ts for
|
||||
* why this can't just be typed as `TFunction`. */
|
||||
type Translate = (key: string, options?: unknown) => string;
|
||||
|
||||
export function evaluateEligibility(
|
||||
application: LicenseApplication,
|
||||
licenseType: LicenseType | undefined,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): EligibilityRule[] {
|
||||
const rules: EligibilityRule[] = [];
|
||||
|
||||
@@ -139,11 +177,18 @@ export function evaluateEligibility(
|
||||
|
||||
rules.push({
|
||||
id: 'capital-threshold',
|
||||
label: `Paid-up capital ≥ ${format(threshold)}`,
|
||||
label: t('review.eligibilityRule.capitalThreshold', {
|
||||
amount: format(threshold),
|
||||
defaultValue: 'Paid-up capital ≥ {{amount}}',
|
||||
}),
|
||||
actual:
|
||||
effective === undefined
|
||||
? 'Not recorded'
|
||||
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
|
||||
? t('review.eligibilityRule.notRecorded', 'Not recorded')
|
||||
: `${format(effective)}${
|
||||
verified === undefined
|
||||
? t('review.eligibilityRule.declaredSuffix', ' (declared)')
|
||||
: t('review.eligibilityRule.verifiedSuffix', ' (verified)')
|
||||
}`,
|
||||
// An unverified declaration is not evidence, so it reads as unknown
|
||||
// rather than as a pass the officer never actually made.
|
||||
status:
|
||||
@@ -167,8 +212,10 @@ export function evaluateEligibility(
|
||||
].includes(application.status);
|
||||
rules.push({
|
||||
id: 'inspection',
|
||||
label: 'Physical inspection completed',
|
||||
actual: inspected ? 'Recorded' : 'Not yet recorded',
|
||||
label: t('review.eligibilityRule.inspectionCompleted', 'Physical inspection completed'),
|
||||
actual: inspected
|
||||
? t('review.eligibilityRule.recorded', 'Recorded')
|
||||
: t('review.eligibilityRule.notYetRecorded', 'Not yet recorded'),
|
||||
status: inspected ? 'pass' : 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
|
||||
import { STATUS_LABELS, localized, type LicenseApplication } from '@ema-platform/api';
|
||||
import { dateDisplayer } from '@ema-platform/shared';
|
||||
import { computeSla } from './sla';
|
||||
|
||||
/**
|
||||
@@ -21,21 +22,19 @@ const COLUMNS: Array<{
|
||||
{ header: 'Company', value: (a) => a.companyName },
|
||||
{ header: 'Trade name', value: (a) => a.tradeName },
|
||||
{ header: 'TIN', value: (a) => a.tinNumber },
|
||||
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
|
||||
{ header: 'Licence type', value: (a, locale) => localized(a.licenseType?.name, locale) || a.licenseTypeId },
|
||||
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
|
||||
{ header: 'Kind', value: (a) => a.kind },
|
||||
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
||||
{
|
||||
header: 'Submitted',
|
||||
value: (a, locale) =>
|
||||
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
|
||||
value: (a, locale) => (a.submittedAt ? dateDisplayer(a.submittedAt, locale) : ''),
|
||||
},
|
||||
{
|
||||
header: 'Decided',
|
||||
value: (a, locale) =>
|
||||
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
|
||||
value: (a, locale) => (a.decidedAt ? dateDisplayer(a.decidedAt, locale) : ''),
|
||||
},
|
||||
{ header: 'SLA', value: (a) => computeSla(a).label },
|
||||
{ header: 'SLA', value: (a, locale) => computeSla(a, undefined, locale).label },
|
||||
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
|
||||
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
|
||||
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -9,7 +8,6 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
MultiSelect,
|
||||
Pagination,
|
||||
Paper,
|
||||
SegmentedControl,
|
||||
Select,
|
||||
@@ -17,29 +15,30 @@ import {
|
||||
Stack,
|
||||
Kbd,
|
||||
Modal,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
APPLICANT_NAME_TYPE_KEYS,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useClaimApplicationMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
@@ -50,9 +49,16 @@ import {
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { computeSla } from '../sla';
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
AdvancedTable,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
AmharicDatePicker,
|
||||
type AdvancedColumn,
|
||||
} from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../sla";
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
@@ -61,30 +67,30 @@ import {
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
type SavedViewId,
|
||||
} from '../queue-views';
|
||||
import { exportApplicationsCsv } from '../export';
|
||||
import { setDensity } from '../../../store/preferences.slice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
|
||||
} from "../queue-views";
|
||||
import { exportApplicationsCsv } from "../export";
|
||||
import { setDensity } from "../../../store/preferences.slice";
|
||||
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../useQueueKeyboard";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'ON_HOLD',
|
||||
'APPROVED',
|
||||
'PAYMENT_PENDING',
|
||||
'PAID',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
"SUBMITTED",
|
||||
"UNDER_REVIEW",
|
||||
"UNDER_EVALUATION",
|
||||
"RESUBMIT_REQUIRED",
|
||||
"INSPECTION_PENDING",
|
||||
"INSPECTION_COMPLETED",
|
||||
"ON_HOLD",
|
||||
"APPROVED",
|
||||
"PAYMENT_PENDING",
|
||||
"PAID",
|
||||
"PAYMENT_CONFIRMED",
|
||||
"CERTIFICATE_ISSUED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -104,11 +110,12 @@ export function LicenseQueuePage() {
|
||||
const density = useAppSelector((state) => state.preferences.density);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() => (searchParams.get('view') as SavedViewId) || readLastView(),
|
||||
() => (searchParams.get("view") as SavedViewId) || readLastView(),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get("q") ?? "");
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
@@ -134,22 +141,42 @@ export function LicenseQueuePage() {
|
||||
...urlFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||||
take: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
// No explicit sort in the URL or view → newest submissions first, so
|
||||
// the queue opens showing what most needs attention rather than
|
||||
// whatever order the backend happens to return. "Mine" sorts by
|
||||
// claimedAt instead — officers care when they picked it up, not when
|
||||
// it was originally submitted.
|
||||
sortBy:
|
||||
urlFilter.sortBy ??
|
||||
(activeView.id === "mine" ? "submittedAt" : "submittedAt"),
|
||||
sortDir: urlFilter.sortDir ?? "DESC",
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
}),
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page, pageSize],
|
||||
);
|
||||
|
||||
// One query per source; the two inactive ones are skipped, so switching
|
||||
// views costs a single request rather than keeping three in flight.
|
||||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
||||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
||||
const queueQuery = useGetQueueQuery(filter, {
|
||||
skip: activeView.source !== "queue",
|
||||
});
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, {
|
||||
skip: activeView.source !== "mine",
|
||||
});
|
||||
const allQuery = useGetAllApplicationsQuery(filter, {
|
||||
skip: activeView.source !== "all",
|
||||
});
|
||||
const active =
|
||||
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
|
||||
activeView.source === "queue"
|
||||
? queueQuery
|
||||
: activeView.source === "mine"
|
||||
? mineQuery
|
||||
: allQuery;
|
||||
|
||||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||||
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
|
||||
const [runExport, { isFetching: exporting }] =
|
||||
useLazyExportApplicationsQuery();
|
||||
|
||||
/**
|
||||
* Exports every row the filter matches, not just the page on screen.
|
||||
@@ -158,24 +185,28 @@ export function LicenseQueuePage() {
|
||||
*/
|
||||
async function handleExport() {
|
||||
try {
|
||||
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
|
||||
const result = await runExport({
|
||||
...filter,
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
}).unwrap();
|
||||
exportApplicationsCsv(result.items, i18n.language);
|
||||
if (result.truncated) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: t('queue.exportTruncated', 'Export truncated'),
|
||||
message: t('queue.exportTruncatedBody', {
|
||||
color: "yellow",
|
||||
title: t("queue.exportTruncated", "Export truncated"),
|
||||
message: t("queue.exportTruncatedBody", {
|
||||
exported: result.items.length,
|
||||
total: result.total,
|
||||
defaultValue:
|
||||
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||
"Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.exportFailed', 'Export failed'),
|
||||
color: "red",
|
||||
title: t("queue.exportFailed", "Export failed"),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
@@ -183,7 +214,6 @@ export function LicenseQueuePage() {
|
||||
|
||||
const items = active.data?.items ?? [];
|
||||
const total = active.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||
@@ -208,9 +238,11 @@ export function LicenseQueuePage() {
|
||||
updateUrl(next, view, 1);
|
||||
};
|
||||
|
||||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
||||
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||
const dir =
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||||
? "DESC"
|
||||
: "ASC";
|
||||
setFacet({ sortBy: field, sortDir: dir });
|
||||
};
|
||||
|
||||
@@ -218,20 +250,23 @@ export function LicenseQueuePage() {
|
||||
try {
|
||||
await claim(id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('queue.claimed', 'Claimed'),
|
||||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||||
color: "teal",
|
||||
title: t("queue.claimed", "Claimed"),
|
||||
message: t(
|
||||
"queue.claimedBody",
|
||||
"The application is now assigned to you.",
|
||||
),
|
||||
});
|
||||
changeView('mine');
|
||||
changeView("mine");
|
||||
} catch (err) {
|
||||
// A 409 means another officer got there first — refresh so the queue
|
||||
// stops showing work that is no longer available.
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.claimFailed', 'Could not claim'),
|
||||
color: "red",
|
||||
title: t("queue.claimFailed", "Could not claim"),
|
||||
message: extractErrorMessage(
|
||||
err,
|
||||
t('queue.claimRace', 'Another officer already claimed it.'),
|
||||
t("queue.claimRace", "Another officer already claimed it."),
|
||||
),
|
||||
});
|
||||
active.refetch();
|
||||
@@ -242,19 +277,22 @@ export function LicenseQueuePage() {
|
||||
const results = await Promise.allSettled(
|
||||
selected.map((id) => claim(id).unwrap()),
|
||||
);
|
||||
const claimed = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const claimed = results.filter((r) => r.status === "fulfilled").length;
|
||||
const lost = results.length - claimed;
|
||||
notifications.show({
|
||||
color: lost ? 'yellow' : 'teal',
|
||||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
||||
color: lost ? "yellow" : "teal",
|
||||
title: t("queue.bulkClaimed", {
|
||||
count: claimed,
|
||||
defaultValue: "{{count}} claimed",
|
||||
}),
|
||||
// Partial success is the normal case in a shared queue, so it is
|
||||
// reported rather than swallowed or treated as total failure.
|
||||
message: lost
|
||||
? t('queue.bulkClaimPartial', {
|
||||
? t("queue.bulkClaimPartial", {
|
||||
count: lost,
|
||||
defaultValue: '{{count}} were already taken by another officer.',
|
||||
defaultValue: "{{count}} were already taken by another officer.",
|
||||
})
|
||||
: '',
|
||||
: "",
|
||||
});
|
||||
setSelected([]);
|
||||
active.refetch();
|
||||
@@ -263,13 +301,15 @@ export function LicenseQueuePage() {
|
||||
const cursorRow = items[cursor];
|
||||
useQueueKeyboard({
|
||||
enabled: !helpOpen,
|
||||
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onNext: () =>
|
||||
setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||
onClaim: () => {
|
||||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||||
// rather than an error the officer has to read.
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null)
|
||||
handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
@@ -277,21 +317,187 @@ export function LicenseQueuePage() {
|
||||
|
||||
const allSelected = items.length > 0 && selected.length === items.length;
|
||||
const sortIcon =
|
||||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
||||
urlFilter.sortDir === "DESC" ? (
|
||||
<IconSortDescending size={13} />
|
||||
) : (
|
||||
<IconSortAscending size={13} />
|
||||
);
|
||||
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
);
|
||||
|
||||
const sortableHeader = (
|
||||
label: string,
|
||||
field: NonNullable<QueueFilter["sortBy"]>,
|
||||
) => (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => toggleSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{urlFilter.sortBy === field && sortIcon}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: (
|
||||
<Checkbox
|
||||
aria-label={t("queue.selectAll", "Select all")}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
),
|
||||
size: 40,
|
||||
cell: ({ row }) => (
|
||||
<Checkbox
|
||||
aria-label={t("queue.selectRow", {
|
||||
number: row.original.applicationNumber,
|
||||
defaultValue: "Select {{number}}",
|
||||
})}
|
||||
checked={selected.includes(row.original.id)}
|
||||
onChange={(e) => {
|
||||
const checked = e.currentTarget.checked;
|
||||
setSelected((prev) =>
|
||||
checked
|
||||
? [...prev, row.original.id]
|
||||
: prev.filter((id) => id !== row.original.id),
|
||||
);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t("queue.number", "App #"), "applicationNumber"),
|
||||
label: t("queue.number", "App #"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: sortableHeader(
|
||||
typeCode && APPLICANT_NAME_TYPE_KEYS.includes(typeCode)
|
||||
? t("queue.applicant", "Applicant")
|
||||
: t("queue.company", "Company"),
|
||||
"companyName",
|
||||
),
|
||||
label: t("queue.company", "Company"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{applicantOrCompanyName(row.original) ?? "—"}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("queue.tin", "TIN"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.tinNumber ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, i18n.language) || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: sortableHeader(t("queue.statusCol", "Status"), "status"),
|
||||
label: t("queue.statusCol", "Status"),
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{t(
|
||||
`queue.statusValues.${row.original.status}`,
|
||||
STATUS_LABELS[row.original.status],
|
||||
)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: sortableHeader(
|
||||
t("queue.submitted", "Submitted"),
|
||||
"submittedAt",
|
||||
),
|
||||
label: t("queue.submitted", "Submitted"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{dateDisplayer(row.original.submittedAt, i18n.language)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t("queue.sla", "Age / SLA"),
|
||||
cell: ({ row }) => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
||||
const sla = computeSla(row.original, undefined, i18n.language, (key, options) => t(key, options as any) as string);
|
||||
return (
|
||||
// Colour is never the only signal — the label says the same thing.
|
||||
<Tooltip label={sla.tooltip} withArrow>
|
||||
<Badge color={sla.color} variant="light" size="sm">
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: "",
|
||||
label: t("queue.actionsColumn", "Actions"),
|
||||
align: "right",
|
||||
size: 140,
|
||||
cell: ({ row }) =>
|
||||
row.original.assignedOfficerId === null &&
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
loading={claiming}
|
||||
onClick={() => handleClaim(row.original.id)}
|
||||
>
|
||||
{t("queue.claim", "Claim")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => navigate(`/licence-review/${row.original.id}`)}
|
||||
>
|
||||
{t("queue.review", "Review")}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
],
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
urlFilter.sortBy,
|
||||
sortIcon,
|
||||
selected,
|
||||
allSelected,
|
||||
items,
|
||||
claiming,
|
||||
typeCode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
@@ -299,18 +505,18 @@ export function LicenseQueuePage() {
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||||
onChange={(v) =>
|
||||
dispatch(setDensity(v as "comfortable" | "compact"))
|
||||
}
|
||||
data={[
|
||||
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||||
{ label: t('queue.compact', 'Compact'), value: 'compact' },
|
||||
{
|
||||
label: t("queue.comfortable", "Comfortable"),
|
||||
value: "comfortable",
|
||||
},
|
||||
{ label: t("queue.compact", "Compact"), value: "compact" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -320,13 +526,17 @@ export function LicenseQueuePage() {
|
||||
loading={exporting}
|
||||
disabled={total === 0}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)}
|
||||
mb="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
@@ -334,7 +544,7 @@ export function LicenseQueuePage() {
|
||||
value={savedView.id}
|
||||
rightSection={
|
||||
counts?.[savedView.countKey] ? (
|
||||
<Badge size="xs" variant="light" circle>
|
||||
<Badge size="xs" variant="light">
|
||||
{counts[savedView.countKey]}
|
||||
</Badge>
|
||||
) : undefined
|
||||
@@ -350,17 +560,20 @@ export function LicenseQueuePage() {
|
||||
<Paper withBorder p="sm" mb="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('queue.search', 'Search')}
|
||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||||
label={t("queue.search", "Search")}
|
||||
placeholder={t("queue.searchPlaceholder", "Company, TIN or number")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('queue.status', 'Status')}
|
||||
placeholder={t('queue.anyStatus', 'Any')}
|
||||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
label={t("queue.status", "Status")}
|
||||
placeholder={t("queue.anyStatus", "Any")}
|
||||
data={ALL_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||||
}))}
|
||||
value={urlFilter.status ?? []}
|
||||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||||
clearable
|
||||
@@ -368,11 +581,11 @@ export function LicenseQueuePage() {
|
||||
/>
|
||||
{!typeCode && (
|
||||
<Select
|
||||
label={t('queue.type', 'Licence type')}
|
||||
placeholder={t('queue.anyType', 'Any')}
|
||||
label={t("queue.type", "Licence type")}
|
||||
placeholder={t("queue.anyType", "Any")}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name.en ?? type.key,
|
||||
label: localized(type.name, i18n.language) || type.key,
|
||||
}))}
|
||||
value={urlFilter.licenseTypeId ?? null}
|
||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||
@@ -380,28 +593,30 @@ export function LicenseQueuePage() {
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedFrom", "Submitted from")}
|
||||
value={urlFilter.submittedFrom ?? ""}
|
||||
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={170}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedTo", "Submitted to")}
|
||||
value={urlFilter.submittedTo ?? ""}
|
||||
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={170}
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearchInput("");
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
{t('queue.clearFilters', 'Clear')}
|
||||
{t("queue.clearFilters", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -418,7 +633,7 @@ export function LicenseQueuePage() {
|
||||
</Stack>
|
||||
) : active.isError ? (
|
||||
<ErrorState
|
||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||||
title={t("queue.errorTitle", "Could not load the queue")}
|
||||
description={extractErrorMessage(active.error)}
|
||||
onRetry={() => active.refetch()}
|
||||
icon={IconAlertCircle}
|
||||
@@ -427,114 +642,73 @@ export function LicenseQueuePage() {
|
||||
<EmptyState
|
||||
title={
|
||||
hasFacets
|
||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||
: t('queue.empty', 'Nothing waiting here')
|
||||
? t(
|
||||
"queue.emptyFiltered",
|
||||
"No applications match these filters",
|
||||
)
|
||||
: t("queue.empty", "Nothing waiting here")
|
||||
}
|
||||
description={
|
||||
hasFacets
|
||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||||
? t(
|
||||
"queue.emptyFilteredBody",
|
||||
"Try widening or clearing the filters.",
|
||||
)
|
||||
: t(
|
||||
"queue.emptyBody",
|
||||
"New applications will appear here as they are submitted.",
|
||||
)
|
||||
}
|
||||
action={
|
||||
hasFacets
|
||||
? {
|
||||
label: t('queue.clearFilters', 'Clear'),
|
||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
label: t("queue.clearFilters", "Clear"),
|
||||
onClick: () =>
|
||||
setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Table.ScrollContainer minWidth={1100}>
|
||||
<Table highlightOnHover verticalSpacing={density === "compact" ? 4 : "sm"}>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th w={40}>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectAll', 'Select all')}
|
||||
checked={allSelected}
|
||||
indeterminate={selected.length > 0 && !allSelected}
|
||||
onChange={() =>
|
||||
setSelected(allSelected ? [] : items.map((a) => a.id))
|
||||
}
|
||||
/>
|
||||
</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.number', 'App #')}
|
||||
field="applicationNumber"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.company', 'Company')}
|
||||
field="companyName"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.tin', 'TIN')}</Table.Th>
|
||||
<Table.Th>{t('queue.typeCol', 'Type')}</Table.Th>
|
||||
<SortableTh
|
||||
label={t('queue.statusCol', 'Status')}
|
||||
field="status"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<SortableTh
|
||||
label={t('queue.submitted', 'Submitted')}
|
||||
field="submittedAt"
|
||||
current={urlFilter.sortBy}
|
||||
icon={sortIcon}
|
||||
onSort={toggleSort}
|
||||
/>
|
||||
<Table.Th>{t('queue.sla', 'Age / SLA')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app, index) => (
|
||||
<QueueRow
|
||||
key={app.id}
|
||||
app={app}
|
||||
focused={index === cursor}
|
||||
selected={selected.includes(app.id)}
|
||||
claiming={claiming}
|
||||
locale={i18n.language}
|
||||
onSelect={(checked) =>
|
||||
setSelected((prev) =>
|
||||
checked ? [...prev, app.id] : prev.filter((id) => id !== app.id),
|
||||
)
|
||||
}
|
||||
onClaim={() => handleClaim(app.id)}
|
||||
onOpen={() => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
|
||||
<Group justify="space-between" p="sm">
|
||||
<Group justify="flex-end" p="sm" pb={0}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
{t("queue.showing", {
|
||||
from: (page - 1) * pageSize + 1,
|
||||
to: Math.min(page * pageSize, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||||
})}
|
||||
</Text>
|
||||
<Pagination
|
||||
value={page}
|
||||
onChange={(next) => {
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
total={pageCount}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={items}
|
||||
tableName={t("queue.title", "Licence applications")}
|
||||
itemCount={total}
|
||||
pageIndex={page - 1}
|
||||
onPageChange={(pageIndex) => {
|
||||
const next = pageIndex + 1;
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(next) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
updateUrl({}, view, 1);
|
||||
}}
|
||||
refresh={() => active.refetch()}
|
||||
isLoading={active.isFetching}
|
||||
verticalSpacing={density === "compact" ? 4 : "sm"}
|
||||
rowStyle={(_row, index) =>
|
||||
// Keyboard cursor. A left border rather than a background keeps
|
||||
// it distinguishable from row selection and from hover.
|
||||
index === cursor
|
||||
? { boxShadow: "inset 3px 0 0 var(--mantine-color-blue-6)" }
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -542,7 +716,7 @@ export function LicenseQueuePage() {
|
||||
<Modal
|
||||
opened={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||||
title={t("shortcuts.title", "Keyboard shortcuts")}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
@@ -562,18 +736,18 @@ export function LicenseQueuePage() {
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="sm"
|
||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||||
style={{ position: "sticky", bottom: 16, zIndex: 50 }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('queue.selectedCount', {
|
||||
{t("queue.selectedCount", {
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
defaultValue: "{{count}} selected",
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -585,12 +759,12 @@ export function LicenseQueuePage() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t('queue.bulkClaim', {
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: 'Claim {{count}}',
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
@@ -601,122 +775,4 @@ export function LicenseQueuePage() {
|
||||
);
|
||||
}
|
||||
|
||||
function SortableTh({
|
||||
label,
|
||||
field,
|
||||
current,
|
||||
icon,
|
||||
onSort,
|
||||
}: {
|
||||
label: string;
|
||||
field: NonNullable<QueueFilter['sortBy']>;
|
||||
current?: QueueFilter['sortBy'];
|
||||
icon: React.ReactNode;
|
||||
onSort: (field: NonNullable<QueueFilter['sortBy']>) => void;
|
||||
}) {
|
||||
return (
|
||||
<Table.Th>
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => onSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{current === field && icon}
|
||||
</Group>
|
||||
</Table.Th>
|
||||
);
|
||||
}
|
||||
|
||||
function QueueRow({
|
||||
app,
|
||||
selected,
|
||||
focused,
|
||||
claiming,
|
||||
locale,
|
||||
onSelect,
|
||||
onClaim,
|
||||
onOpen,
|
||||
}: {
|
||||
app: LicenseApplication;
|
||||
selected: boolean;
|
||||
focused: boolean;
|
||||
claiming: boolean;
|
||||
locale: string;
|
||||
onSelect: (checked: boolean) => void;
|
||||
onClaim: () => void;
|
||||
onOpen: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const sla = computeSla(app);
|
||||
|
||||
return (
|
||||
<Table.Tr
|
||||
// Keyboard cursor. Marked with a left border rather than a background so
|
||||
// it stays distinguishable from row selection and from hover.
|
||||
style={
|
||||
focused
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Table.Td>
|
||||
<Checkbox
|
||||
aria-label={t('queue.selectRow', { number: app.applicationNumber, defaultValue: 'Select {{number}}' })}
|
||||
checked={selected}
|
||||
onChange={(e) => onSelect(e.currentTarget.checked)}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{/* Colour is never the only signal — the label says the same thing. */}
|
||||
<Tooltip label={sla.tooltip} withArrow>
|
||||
<Badge color={sla.color} variant="light" size="sm">
|
||||
{sla.label}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={claiming} onClick={onClaim}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={onOpen}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseQueuePage;
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetInspectionsQuery,
|
||||
useGetAssignableOfficersQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
@@ -57,7 +59,8 @@ import {
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
} from '@ema-platform/api';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { ErrorState, ModalFooter, AmharicDatePicker } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../store/hooks';
|
||||
import { DecisionBar } from '../components/DecisionBar';
|
||||
@@ -79,10 +82,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
||||
* that fills it in.
|
||||
*/
|
||||
const INSPECTION_CHECKLIST_ITEMS = [
|
||||
{ key: 'office_premises', label: 'Office premises' },
|
||||
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
|
||||
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
|
||||
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
|
||||
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
|
||||
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
|
||||
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
|
||||
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
|
||||
] as const;
|
||||
|
||||
function buildChecklist(
|
||||
@@ -90,7 +93,9 @@ function buildChecklist(
|
||||
) {
|
||||
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
// Persisted as-is (stable English), not the officer's display language —
|
||||
// this is an audit record, not UI text.
|
||||
label: item.fallback,
|
||||
// Untouched rows default to PASS — the segmented control shows exactly
|
||||
// that, so what the officer saw is what gets recorded.
|
||||
outcome: outcomes[item.key] ?? 'PASS',
|
||||
@@ -108,6 +113,8 @@ function buildChecklist(
|
||||
*/
|
||||
export function LicenseReviewPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { id = '' } = useParams();
|
||||
const { can } = usePermissions();
|
||||
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
||||
@@ -122,6 +129,13 @@ export function LicenseReviewPage() {
|
||||
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
|
||||
{ skip: !data?.application.licenseTypeId },
|
||||
);
|
||||
// Staff role names are already bilingual on the config — the wire data only
|
||||
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
|
||||
// the label an officer reads.
|
||||
const roleNameByKey = useMemo(
|
||||
() => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []),
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
@@ -200,7 +214,7 @@ export function LicenseReviewPage() {
|
||||
return {
|
||||
key,
|
||||
label: member
|
||||
? `${member.roleKey} — ${member.fullName}`
|
||||
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||
: t('review.staffMember', 'Staff member'),
|
||||
};
|
||||
}
|
||||
@@ -210,7 +224,7 @@ export function LicenseReviewPage() {
|
||||
return { key, label: key };
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[flags, data?.staff, t],
|
||||
[flags, data?.staff, t, localized, roleNameByKey],
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
@@ -267,8 +281,10 @@ export function LicenseReviewPage() {
|
||||
const app = data.application;
|
||||
const status = app.status;
|
||||
const presentation = presentationFor(app.licenseType?.key);
|
||||
const sla = computeSla(app);
|
||||
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
||||
const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
||||
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string);
|
||||
|
||||
const rawThreshold = app.licenseType?.capitalThreshold;
|
||||
const threshold =
|
||||
@@ -482,6 +498,12 @@ export function LicenseReviewPage() {
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
// The bilingual section/field labels the applicant's wizard renders — this
|
||||
// page already fetches them (`requirements` above) but used to fall back to
|
||||
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
||||
// either language.
|
||||
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
||||
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -493,7 +515,7 @@ export function LicenseReviewPage() {
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||
{STATUS_LABELS[status]}
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
{app.adjustmentRound > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
@@ -530,16 +552,12 @@ export function LicenseReviewPage() {
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
|
||||
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
|
||||
<SummaryRow
|
||||
label={t('review.submitted', 'Submitted')}
|
||||
value={
|
||||
app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
|
||||
: undefined
|
||||
}
|
||||
value={showDate(app.submittedAt)}
|
||||
/>
|
||||
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
|
||||
</Stack>
|
||||
@@ -598,12 +616,12 @@ export function LicenseReviewPage() {
|
||||
key={entry.id}
|
||||
title={
|
||||
<Text size="xs" fw={600}>
|
||||
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
|
||||
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
||||
{showDate(entry.createdAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
@@ -640,11 +658,18 @@ export function LicenseReviewPage() {
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => (
|
||||
{formSections.map(([sectionKey, values]) => {
|
||||
const sectionConfig = sectionsByKey.get(sectionKey);
|
||||
const fieldsByKey = new Map(
|
||||
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
{sectionConfig
|
||||
? localized(sectionConfig.title)
|
||||
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
@@ -655,18 +680,21 @@ export function LicenseReviewPage() {
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => (
|
||||
{Object.entries(values ?? {}).map(([k, v]) => {
|
||||
const fieldConfig = fieldsByKey.get(k);
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{k}
|
||||
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
@@ -703,7 +731,8 @@ export function LicenseReviewPage() {
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -779,19 +808,13 @@ export function LicenseReviewPage() {
|
||||
{data.staff.map((member) => (
|
||||
<Table.Tr key={member.id}>
|
||||
<Table.Td>
|
||||
<Text size="xs">{member.roleKey}</Text>
|
||||
<Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{member.fullName}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={4}>
|
||||
{(member.documents ?? []).map((doc) => (
|
||||
<Badge key={doc.id} size="xs" variant="light">
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
|
||||
</Table.Td>
|
||||
{/* A person's papers are as returnable as a document
|
||||
or a form section: an ERB certificate for the wrong
|
||||
@@ -853,7 +876,7 @@ export function LicenseReviewPage() {
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{inspection.scheduledDate
|
||||
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
|
||||
? showDate(inspection.scheduledDate)
|
||||
: t('review.unscheduled', 'Not scheduled')}
|
||||
</Text>
|
||||
{inspection.findings && (
|
||||
@@ -866,7 +889,11 @@ export function LicenseReviewPage() {
|
||||
variant="light"
|
||||
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
|
||||
>
|
||||
{inspection.result ?? inspection.status}
|
||||
{inspection.result === 'PASSED'
|
||||
? t('review.passed', 'Passed')
|
||||
: inspection.result === 'FAILED'
|
||||
? t('review.failed', 'Failed')
|
||||
: t(`review.inspectionStatus.${inspection.status}`, inspection.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
@@ -921,13 +948,13 @@ export function LicenseReviewPage() {
|
||||
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
<AmharicDatePicker
|
||||
label={t('review.dateTime', 'Date and time')}
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.currentTarget.value)}
|
||||
onChange={setInspectionDate}
|
||||
withTime
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
label={t('review.pickDate', 'Pick a date and time first')}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
@@ -949,7 +976,7 @@ export function LicenseReviewPage() {
|
||||
run(async () => {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: new Date(inspectionDate).toISOString(),
|
||||
scheduledDate: inspectionDate,
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
}, t('review.done.scheduled', 'Inspection scheduled'))
|
||||
@@ -957,7 +984,7 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -972,7 +999,7 @@ export function LicenseReviewPage() {
|
||||
<Stack gap={6}>
|
||||
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
||||
<Group key={item.key} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{item.label}</Text>
|
||||
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={checklist[item.key] ?? 'PASS'}
|
||||
@@ -1005,7 +1032,7 @@ export function LicenseReviewPage() {
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
<Group grow>
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -1054,13 +1081,58 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence badges for one staff member.
|
||||
*
|
||||
* `application-for-review` nests a `documents` array per staff member, but it
|
||||
* doesn't always carry the uploaded file (the portal's own upload widget
|
||||
* hits the attachments endpoint directly for the same reason). Query
|
||||
* attachments by owner here too, so the officer gets a working link instead
|
||||
* of a badge with nowhere to go.
|
||||
*/
|
||||
function StaffEvidenceCell({
|
||||
staffId,
|
||||
fallback,
|
||||
}: {
|
||||
staffId: string;
|
||||
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
|
||||
}) {
|
||||
const { data: attachments } = useGetAttachmentsQuery({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
ownerId: staffId,
|
||||
});
|
||||
const docs = attachments?.length ? attachments : (fallback ?? []);
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{docs.map((doc) => {
|
||||
const url = doc.files?.[0]?.url;
|
||||
return (
|
||||
<Badge
|
||||
key={doc.id}
|
||||
size="xs"
|
||||
variant="light"
|
||||
component={url ? 'a' : undefined}
|
||||
href={url}
|
||||
target={url ? '_blank' : undefined}
|
||||
rel={url ? 'noreferrer' : undefined}
|
||||
style={url ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
import { dateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** Amber once this much of the window has been consumed. */
|
||||
const WARNING_RATIO = 0.7;
|
||||
@@ -17,6 +18,19 @@ export interface SlaState {
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
/** Loose enough to accept i18next's real `t` structurally; callers pass that
|
||||
* one, and a plain function falls back to English by resolving
|
||||
* `defaultValue` itself, so `computeSla` still works with no i18n context
|
||||
* (the CSV export). */
|
||||
export type SlaTranslate = (key: string, options?: unknown) => string;
|
||||
|
||||
function tr(t: SlaTranslate | undefined, key: string, options: Record<string, unknown> | string): string {
|
||||
if (t) return t(key, options);
|
||||
if (typeof options === 'string') return options;
|
||||
const template = String(options['defaultValue'] ?? '');
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name] ?? ''));
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
||||
if (hours < 1) return '<1h';
|
||||
@@ -35,6 +49,8 @@ function formatDuration(ms: number): string {
|
||||
export function computeSla(
|
||||
application: LicenseApplication,
|
||||
now: number = Date.now(),
|
||||
language = 'en',
|
||||
t?: SlaTranslate,
|
||||
): SlaState {
|
||||
const slaHours = application.licenseType?.slaHours;
|
||||
const submittedAt = application.submittedAt;
|
||||
@@ -44,7 +60,7 @@ export function computeSla(
|
||||
state: 'untracked',
|
||||
color: 'gray',
|
||||
label: '—',
|
||||
tooltip: 'No turnaround target is set for this licence type.',
|
||||
tooltip: tr(t, 'review.sla.untracked', 'No turnaround target is set for this licence type.'),
|
||||
ratio: 0,
|
||||
};
|
||||
}
|
||||
@@ -54,15 +70,23 @@ export function computeSla(
|
||||
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
||||
const window = slaHours * HOUR_MS;
|
||||
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
|
||||
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
|
||||
const targetText = tr(t, 'review.sla.target', {
|
||||
hours: slaHours,
|
||||
date: dateDisplayer(target, language),
|
||||
defaultValue: 'Target {{hours}}h from submission ({{date}})',
|
||||
});
|
||||
|
||||
if (application.decidedAt) {
|
||||
const met = elapsed <= window;
|
||||
return {
|
||||
state: 'decided',
|
||||
color: met ? 'teal' : 'gray',
|
||||
label: met ? 'Met' : 'Missed',
|
||||
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
|
||||
label: tr(t, met ? 'review.sla.met' : 'review.sla.missed', met ? 'Met' : 'Missed'),
|
||||
tooltip: tr(t, 'review.sla.decidedIn', {
|
||||
duration: formatDuration(elapsed),
|
||||
target: targetText,
|
||||
defaultValue: 'Decided in {{duration}}. {{target}}',
|
||||
}),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
@@ -72,8 +96,15 @@ export function computeSla(
|
||||
return {
|
||||
state: 'breached',
|
||||
color: 'red',
|
||||
label: `Overdue ${formatDuration(remaining)}`,
|
||||
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
|
||||
label: tr(t, 'review.sla.overdue', {
|
||||
duration: formatDuration(remaining),
|
||||
defaultValue: 'Overdue {{duration}}',
|
||||
}),
|
||||
tooltip: tr(t, 'review.sla.overdueBy', {
|
||||
duration: formatDuration(remaining),
|
||||
target: targetText,
|
||||
defaultValue: 'Overdue by {{duration}}. {{target}}',
|
||||
}),
|
||||
ratio: 1,
|
||||
};
|
||||
}
|
||||
@@ -82,8 +113,15 @@ export function computeSla(
|
||||
return {
|
||||
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
||||
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
||||
label: `${formatDuration(remaining)} left`,
|
||||
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
|
||||
label: tr(t, 'review.sla.left', {
|
||||
duration: formatDuration(remaining),
|
||||
defaultValue: '{{duration}} left',
|
||||
}),
|
||||
tooltip: tr(t, 'review.sla.remaining', {
|
||||
duration: formatDuration(remaining),
|
||||
target: targetText,
|
||||
defaultValue: '{{duration}} remaining. {{target}}',
|
||||
}),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
rem,
|
||||
Center,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
|
||||
interface LocationTreeProps {
|
||||
selectedId: string | null;
|
||||
@@ -50,10 +52,15 @@ function TreeNode({
|
||||
onSelect: (location: Location) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const [opened, setOpened] = useState(depth < 1);
|
||||
const isSelected = selectedId === location.id;
|
||||
const hasChildren =
|
||||
Array.isArray(location.children) && location.children.length > 0;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const hoverBg = colorScheme === 'dark'
|
||||
? 'var(--mantine-color-dark-6)'
|
||||
: 'var(--mantine-color-gray-0)';
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((prev) => !prev);
|
||||
@@ -88,8 +95,7 @@ function TreeNode({
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'var(--mantine-color-gray-0)';
|
||||
e.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
@@ -130,7 +136,7 @@ function TreeNode({
|
||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||
/>
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{location.names.en}
|
||||
{localized(location.names)}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
{hasChildren && (
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useUpdateLocationTypeMutation,
|
||||
useDeleteLocationTypeMutation,
|
||||
} from '../api/location-api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
|
||||
interface LocationTypeFormValues {
|
||||
code: string;
|
||||
@@ -34,6 +34,7 @@ interface LocationTypeFormValues {
|
||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
||||
const [createType] = useCreateLocationTypeMutation();
|
||||
const [updateType] = useUpdateLocationTypeMutation();
|
||||
@@ -84,8 +85,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
try {
|
||||
await deleteType(id).unwrap();
|
||||
notify.success(t('location.typeDeleted'));
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +100,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
notify.success(t('location.typeCreated'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('location.typeError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -33,6 +33,7 @@ import type { Location } from '../types/location';
|
||||
export function LocationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
||||
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
||||
@@ -82,11 +83,11 @@ export function LocationPage() {
|
||||
closeFormModal();
|
||||
setEditingLocation(null);
|
||||
setParentLocation(null);
|
||||
} catch {
|
||||
notify.error(t('location.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
},
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, t],
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, handleError],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
@@ -96,10 +97,10 @@ export function LocationPage() {
|
||||
notify.success(t('location.deleted'));
|
||||
setSelectedLocation(null);
|
||||
closeDeleteModal();
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
|
||||
|
||||
if (typesLoading) {
|
||||
return (
|
||||
@@ -225,14 +226,14 @@ export function LocationPage() {
|
||||
name: selectedLocation?.names[locale] ?? '',
|
||||
})}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDeleteModal} size="sm">
|
||||
{t('location.cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeleteConfirm} size="sm">
|
||||
{t('location.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
|
||||
|
||||
@@ -1,15 +1,15 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
@@ -18,12 +18,17 @@ import {
|
||||
import {
|
||||
IconAnchor,
|
||||
IconCheck,
|
||||
IconEye,
|
||||
IconInbox,
|
||||
IconPaperclip,
|
||||
IconStethoscope,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetAttachmentsQuery,
|
||||
useGetPendingMedicalQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
@@ -35,6 +40,8 @@ import type {
|
||||
SeafarerProfileSummary,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
if (!profile) return '—';
|
||||
return (
|
||||
@@ -58,12 +65,13 @@ function RejectModal({
|
||||
onConfirm: (remark: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="What must the seafarer fix?"
|
||||
label={t('recordVerification.rejectReasonLabel', 'What must the seafarer fix?')}
|
||||
required
|
||||
minRows={2}
|
||||
value={remark}
|
||||
@@ -71,7 +79,7 @@ function RejectModal({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
{t('recordVerification.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -82,7 +90,7 @@ function RejectModal({
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -90,6 +98,93 @@ function RejectModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** Attachments / Evidence Modal for viewing uploaded files for a record */
|
||||
function AttachmentsModal({
|
||||
opened,
|
||||
ownerType,
|
||||
ownerId,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
opened: boolean;
|
||||
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
||||
ownerId: string | null;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: attachments, isLoading } = useGetAttachmentsQuery(
|
||||
{ ownerType, ownerId: ownerId ?? '' },
|
||||
{ skip: !ownerId },
|
||||
);
|
||||
|
||||
const files = useMemo(
|
||||
() => (attachments ?? []).flatMap((a) => a.files ?? []),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size="md" centered>
|
||||
<Stack gap="sm">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : files.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Group gap="xs" c="dimmed">
|
||||
<IconInbox size={18} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'recordVerification.noAttachments',
|
||||
'No evidence attachments uploaded for this record.',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<Paper key={file.id} withBorder p="xs" radius="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IconPaperclip size={18} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.originalName}
|
||||
</Text>
|
||||
{file.sizeBytes && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{(file.sizeBytes / 1024).toFixed(1)} KB
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{file.url ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
component="a"
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('recordVerification.view', 'View')}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('recordVerification.noUrl', 'No URL')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||
@@ -97,234 +192,383 @@ function RejectModal({
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const { data: pendingMedical, isLoading: loadingMedical } =
|
||||
useGetPendingMedicalQuery();
|
||||
const { data: pendingSeaService, isLoading: loadingSeaService } =
|
||||
useGetPendingSeaServiceQuery();
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: pendingMedical,
|
||||
isLoading: loadingMedical,
|
||||
isFetching: fetchingMedical,
|
||||
refetch: refetchMedical,
|
||||
} = useGetPendingMedicalQuery();
|
||||
|
||||
const {
|
||||
data: pendingSeaService,
|
||||
isLoading: loadingSeaService,
|
||||
isFetching: fetchingSeaService,
|
||||
refetch: refetchSeaService,
|
||||
} = useGetPendingSeaServiceQuery();
|
||||
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||
useVerifySeaServiceRecordMutation();
|
||||
|
||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||
null,
|
||||
);
|
||||
const [rejectSeaService, setRejectSeaService] =
|
||||
useState<SeaServiceRecord | null>(null);
|
||||
|
||||
const rule = async (
|
||||
run: () => Promise<unknown>,
|
||||
done: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
||||
}
|
||||
};
|
||||
const [attachmentModal, setAttachmentModal] = useState<{
|
||||
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
||||
ownerId: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const [medicalPage, setMedicalPage] = useState(0);
|
||||
const [seaServicePage, setSeaServicePage] = useState(0);
|
||||
const [medicalPageSize, setMedicalPageSize] = useState(PAGE_SIZE);
|
||||
const [seaServicePageSize, setSeaServicePageSize] = useState(PAGE_SIZE);
|
||||
|
||||
const rule = useCallback(
|
||||
async (run: () => Promise<unknown>, done: string): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('recordVerification.rulingFailed', 'Could not record the ruling')),
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const pendingMedicalList = useMemo(
|
||||
() => pendingMedical ?? [],
|
||||
[pendingMedical],
|
||||
);
|
||||
const pendingSeaServiceList = useMemo(
|
||||
() => pendingSeaService ?? [],
|
||||
[pendingSeaService],
|
||||
);
|
||||
|
||||
const pagedMedical = useMemo(() => {
|
||||
const start = medicalPage * medicalPageSize;
|
||||
return pendingMedicalList.slice(start, start + medicalPageSize);
|
||||
}, [pendingMedicalList, medicalPage, medicalPageSize]);
|
||||
|
||||
const pagedSeaService = useMemo(() => {
|
||||
const start = seaServicePage * seaServicePageSize;
|
||||
return pendingSeaServiceList.slice(start, start + seaServicePageSize);
|
||||
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
|
||||
|
||||
const handleMedicalPageSizeChange = useCallback((size: number) => {
|
||||
setMedicalPageSize(size);
|
||||
setMedicalPage(0);
|
||||
}, []);
|
||||
|
||||
const handleSeaServicePageSizeChange = useCallback((size: number) => {
|
||||
setSeaServicePageSize(size);
|
||||
setSeaServicePage(0);
|
||||
}, []);
|
||||
|
||||
const medicalColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: t('recordVerification.columns.seafarer', 'Seafarer'),
|
||||
label: t('recordVerification.columns.seafarer', 'Seafarer'),
|
||||
accessorKey: 'profile.firstName',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(row.original.profile)}
|
||||
</Text>
|
||||
{row.original.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{row.original.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.issuer', 'Issuer'),
|
||||
label: t('recordVerification.columns.issuer', 'Issuer'),
|
||||
accessorKey: 'issuerName',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm">{row.original.issuerName}</Text>
|
||||
{row.original.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {row.original.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.validity', 'Validity'),
|
||||
label: t('recordVerification.columns.validity', 'Validity'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{showDate(row.original.issueDate)} → {showDate(row.original.expiryDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.fitness', 'Fitness'),
|
||||
label: t('recordVerification.columns.fitness', 'Fitness'),
|
||||
accessorKey: 'fitnessStatus',
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light">
|
||||
{t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('recordVerification.columns.actions', 'Actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<IconPaperclip size={14} />}
|
||||
onClick={() =>
|
||||
setAttachmentModal({
|
||||
ownerType: 'MEDICAL_CERTIFICATE',
|
||||
ownerId: row.original.id,
|
||||
title: t('recordVerification.evidenceTitleMedical', {
|
||||
name: ownerName(row.original.profile),
|
||||
defaultValue: 'Evidence for {{name}} Medical Certificate',
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('recordVerification.evidence', 'Evidence')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingMedical}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: row.original.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
t('recordVerification.certificateVerified', 'Certificate verified'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectMedical(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[rulingMedical, rule, verifyMedical, showDate, t],
|
||||
);
|
||||
|
||||
const seaServiceColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
|
||||
() => [
|
||||
{
|
||||
header: t('recordVerification.columns.seafarer', 'Seafarer'),
|
||||
label: t('recordVerification.columns.seafarer', 'Seafarer'),
|
||||
accessorKey: 'profile.firstName',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(row.original.profile)}
|
||||
</Text>
|
||||
{row.original.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{row.original.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.vessel', 'Vessel'),
|
||||
label: t('recordVerification.columns.vessel', 'Vessel'),
|
||||
accessorKey: 'vesselName',
|
||||
cell: ({ row }) => (
|
||||
<div>
|
||||
<Text size="sm">{row.original.vesselName}</Text>
|
||||
{row.original.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {row.original.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.rank', 'Rank'),
|
||||
label: t('recordVerification.columns.rank', 'Rank'),
|
||||
accessorKey: 'rank',
|
||||
cell: ({ row }) => <Text size="sm">{row.original.rank}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('recordVerification.columns.period', 'Period'),
|
||||
label: t('recordVerification.columns.period', 'Period'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{showDate(row.original.engagementDate)} → {showDate(row.original.dischargeDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('recordVerification.columns.actions', 'Actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="blue"
|
||||
variant="light"
|
||||
leftSection={<IconPaperclip size={14} />}
|
||||
onClick={() =>
|
||||
setAttachmentModal({
|
||||
ownerType: 'SEA_SERVICE_RECORD',
|
||||
ownerId: row.original.id,
|
||||
title: t('recordVerification.evidenceTitleSeaService', {
|
||||
name: ownerName(row.original.profile),
|
||||
defaultValue: 'Evidence for {{name}} Sea Service Record',
|
||||
}),
|
||||
})
|
||||
}
|
||||
>
|
||||
{t('recordVerification.evidence', 'Evidence')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingSeaService}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: row.original.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectSeaService(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
],
|
||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Record verification
|
||||
{t('recordVerification.title', 'Record verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Submitted sea-service records and medical certificates awaiting a
|
||||
ruling. Verified records are frozen; rejections return to the seafarer
|
||||
with your remark.
|
||||
{t(
|
||||
'recordVerification.subtitle',
|
||||
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical ({pendingMedical?.length ?? 0})
|
||||
{t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service ({pendingSeaService?.length ?? 0})
|
||||
{t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingMedical ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingMedical ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingMedical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(certificate.profile)}
|
||||
</Text>
|
||||
{certificate.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{certificate.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issuerName}
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light">
|
||||
{certificate.fitnessStatus}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingMedical}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Certificate verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectMedical(certificate)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
columns={medicalColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
{loadingSeaService ? (
|
||||
<Center h={160}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : (pendingSeaService ?? []).length === 0 ? (
|
||||
<Center h={120}>
|
||||
<Text size="sm" c="dimmed">
|
||||
Nothing awaiting verification.
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Seafarer</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(pendingSeaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(record.profile)}
|
||||
</Text>
|
||||
{record.profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{record.profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={rulingSeaService}
|
||||
onClick={() =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Sea-service record verified',
|
||||
)
|
||||
}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => setRejectSeaService(record)}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns}
|
||||
data={pagedSeaService}
|
||||
tableName={t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
itemCount={pendingSeaServiceList.length}
|
||||
pageIndex={seaServicePage}
|
||||
onPageChange={setSeaServicePage}
|
||||
pageSize={seaServicePageSize}
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<AttachmentsModal
|
||||
opened={Boolean(attachmentModal)}
|
||||
ownerType={attachmentModal?.ownerType ?? 'MEDICAL_CERTIFICATE'}
|
||||
ownerId={attachmentModal?.ownerId ?? null}
|
||||
title={attachmentModal?.title ?? t('recordVerification.evidenceModalTitle', 'Evidence Attachments')}
|
||||
onClose={() => setAttachmentModal(null)}
|
||||
/>
|
||||
|
||||
<RejectModal
|
||||
title="Reject medical certificate"
|
||||
title={t('recordVerification.rejectMedicalTitle', 'Reject medical certificate')}
|
||||
opened={Boolean(rejectMedical)}
|
||||
onClose={() => setRejectMedical(null)}
|
||||
loading={rulingMedical}
|
||||
@@ -337,13 +581,13 @@ export function MedicalVerificationPage() {
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Certificate rejected',
|
||||
t('recordVerification.certificateRejected', 'Certificate rejected'),
|
||||
);
|
||||
setRejectMedical(null);
|
||||
}}
|
||||
/>
|
||||
<RejectModal
|
||||
title="Reject sea-service record"
|
||||
title={t('recordVerification.rejectSeaServiceTitle', 'Reject sea-service record')}
|
||||
opened={Boolean(rejectSeaService)}
|
||||
onClose={() => setRejectSeaService(null)}
|
||||
loading={rulingSeaService}
|
||||
@@ -356,7 +600,7 @@ export function MedicalVerificationPage() {
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Sea-service record rejected',
|
||||
t('recordVerification.seaServiceRejected', 'Sea-service record rejected'),
|
||||
);
|
||||
setRejectSeaService(null);
|
||||
}}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -12,7 +12,6 @@ import {
|
||||
Paper,
|
||||
Stack,
|
||||
Switch,
|
||||
Table,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
@@ -26,10 +25,16 @@ import {
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
@@ -55,9 +60,13 @@ function feeText(amount: string | number | null, currency: string): string {
|
||||
}
|
||||
|
||||
export function PaymentConfigPage() {
|
||||
const { data, isLoading, error } = useGetLicenseTypesQuery();
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data, isLoading, isFetching, error, refetch } =
|
||||
useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -72,7 +81,7 @@ export function PaymentConfigPage() {
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="Could not load licence types"
|
||||
title={t('paymentConfig.loadError', 'Could not load licence types')}
|
||||
>
|
||||
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||
</Alert>
|
||||
@@ -82,15 +91,113 @@ export function PaymentConfigPage() {
|
||||
const types = [...(data?.items ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
const page = paginate(types);
|
||||
|
||||
const columns: AdvancedColumn<LicenseType>[] = [
|
||||
{
|
||||
header: t('paymentConfig.columns.type', 'Licence type'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{row.original.key}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('paymentConfig.columns.newApplication', 'New application'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('paymentConfig.columns.renewal', 'Renewal'),
|
||||
cell: ({ row }) => {
|
||||
const type = row.original;
|
||||
if (type.feeNewApplication === null) {
|
||||
// No charge at all, so "same as new" would be noise.
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
);
|
||||
}
|
||||
if (type.feeRenewal === null) {
|
||||
return (
|
||||
<Tooltip
|
||||
label={t(
|
||||
'paymentConfig.renewalSameTooltip',
|
||||
'No separate renewal fee — renewal is charged at the new-application rate',
|
||||
)}
|
||||
>
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
{t('paymentConfig.renewalSameAsNew', '(same as new)')}
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
header: t('paymentConfig.columns.charged', 'Charged?'),
|
||||
cell: ({ row }) =>
|
||||
row.original.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
{t('paymentConfig.chargedOnApproval', 'On approval')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip
|
||||
label={t(
|
||||
'paymentConfig.chargedNotChargedTooltip',
|
||||
'This licence type ends with an EMA decision and never reaches a payment stage',
|
||||
)}
|
||||
>
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
{t('paymentConfig.chargedNotCharged', 'Not charged')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
size: 90,
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => setEditing(row.original)}
|
||||
>
|
||||
{t('paymentConfig.edit', 'Edit')}
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment configuration</Title>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
What each licence costs. Applicants are charged after approval, and
|
||||
the amount is fixed onto the application at that moment.
|
||||
{t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
@@ -105,90 +212,25 @@ export function PaymentConfigPage() {
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
A change applies to applications approved from now on. Anything
|
||||
already approved keeps the amount it was quoted, so an edit here can
|
||||
never alter what an applicant has already been asked to pay.
|
||||
{t(
|
||||
'paymentConfig.changeNotice',
|
||||
'A change applies to applications approved from now on. Anything already approved keeps the amount it was quoted, so an edit here can never alter what an applicant has already been asked to pay.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={820}>
|
||||
<Table highlightOnHover verticalSpacing="sm">
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Licence type</Table.Th>
|
||||
<Table.Th>New application</Table.Th>
|
||||
<Table.Th>Renewal</Table.Th>
|
||||
<Table.Th>Charged?</Table.Th>
|
||||
<Table.Th w={90} />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{types.map((type) => (
|
||||
<Table.Tr key={type.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{type.feeNewApplication === null ? (
|
||||
// No charge at all, so "same as new" would be noise.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
(same as new)
|
||||
</Text>
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeRenewal, type.feeCurrency)}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{type.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<Badge variant="light" color="gray" size="sm">
|
||||
Not charged
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => setEditing(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
tableName="payment-config-license-types"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||
|
||||
@@ -203,6 +245,7 @@ export function PaymentConfigPage() {
|
||||
* them as editable fields would be a lie.
|
||||
*/
|
||||
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="xs">
|
||||
@@ -210,23 +253,31 @@ function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
<IconLock size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Payment gateway
|
||||
{t('paymentConfig.gateway.title', 'Payment gateway')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Set by the payment service environment — not editable here.
|
||||
{t(
|
||||
'paymentConfig.gateway.subtitle',
|
||||
'Set by the payment service environment — not editable here.',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light">Telebirr</Badge>
|
||||
<Badge variant="light">{t('paymentConfig.gateway.provider', 'Telebirr')}</Badge>
|
||||
{bypassEnabled ? (
|
||||
<Tooltip label="ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.">
|
||||
<Tooltip
|
||||
label={t(
|
||||
'paymentConfig.gateway.bypassTooltip',
|
||||
'ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.',
|
||||
)}
|
||||
>
|
||||
<Badge variant="light" color="orange">
|
||||
Test bypass enabled
|
||||
{t('paymentConfig.gateway.bypassEnabled', 'Test bypass enabled')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
Test bypass off
|
||||
{t('paymentConfig.gateway.bypassOff', 'Test bypass off')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -241,6 +292,8 @@ function FeeEditModal({
|
||||
licenseType: LicenseType | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
||||
const [newFee, setNewFee] = useState<number | ''>('');
|
||||
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
||||
@@ -268,11 +321,21 @@ function FeeEditModal({
|
||||
async function save() {
|
||||
if (!licenseType) return;
|
||||
if (chargeable && newFee === '') {
|
||||
notify.error('Enter a new-application fee, or turn off "carries a fee".');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingNewFee',
|
||||
'Enter a new-application fee, or turn off "carries a fee".',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (chargeable && !sameAsNew && renewalFee === '') {
|
||||
notify.error('Enter a renewal fee, or charge renewal at the same rate.');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingRenewalFee',
|
||||
'Enter a renewal fee, or charge renewal at the same rate.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -282,10 +345,17 @@ function FeeEditModal({
|
||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||
feeCurrency: currency.trim() || 'ETB',
|
||||
}).unwrap();
|
||||
notify.success(`${localized(licenseType.name)} fees updated.`);
|
||||
notify.success(
|
||||
t('paymentConfig.modal.updated', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: '{{type}} fees updated.',
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not save the fees'));
|
||||
notify.error(
|
||||
extractErrorMessage(err, t('paymentConfig.modal.saveFailed', 'Could not save the fees')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -293,7 +363,14 @@ function FeeEditModal({
|
||||
<Modal
|
||||
opened={!!licenseType}
|
||||
onClose={onClose}
|
||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
||||
title={
|
||||
licenseType
|
||||
? t('paymentConfig.modal.title', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: 'Fees — {{type}}',
|
||||
})
|
||||
: ''
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
@@ -306,9 +383,10 @@ function FeeEditModal({
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
This licence type concludes with an EMA decision and never
|
||||
reaches a payment stage, so a fee set here stays unused until
|
||||
that changes.
|
||||
{t(
|
||||
'paymentConfig.modal.noPaymentStage',
|
||||
'This licence type concludes with an EMA decision and never reaches a payment stage, so a fee set here stays unused until that changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -316,14 +394,17 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={chargeable}
|
||||
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
||||
label="This licence carries a fee"
|
||||
description="Turn off for licence types applicants are never charged for."
|
||||
label={t('paymentConfig.modal.chargeableLabel', 'This licence carries a fee')}
|
||||
description={t(
|
||||
'paymentConfig.modal.chargeableDescription',
|
||||
'Turn off for licence types applicants are never charged for.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{chargeable && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="New application fee"
|
||||
label={t('paymentConfig.modal.newFeeLabel', 'New application fee')}
|
||||
value={newFee}
|
||||
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -336,13 +417,16 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={sameAsNew}
|
||||
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
||||
label="Charge renewal at the same rate"
|
||||
description="Turn off to set a separate renewal fee."
|
||||
label={t('paymentConfig.modal.sameRateLabel', 'Charge renewal at the same rate')}
|
||||
description={t(
|
||||
'paymentConfig.modal.sameRateDescription',
|
||||
'Turn off to set a separate renewal fee.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{!sameAsNew && (
|
||||
<NumberInput
|
||||
label="Renewal fee"
|
||||
label={t('paymentConfig.modal.renewalFeeLabel', 'Renewal fee')}
|
||||
value={renewalFee}
|
||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -354,7 +438,7 @@ function FeeEditModal({
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Currency"
|
||||
label={t('paymentConfig.modal.currencyLabel', 'Currency')}
|
||||
value={currency}
|
||||
onChange={(e) =>
|
||||
setCurrency(e.currentTarget.value.toUpperCase())
|
||||
@@ -364,14 +448,14 @@ function FeeEditModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<ModalFooter mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
{t('paymentConfig.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} loading={isLoading}>
|
||||
Save fees
|
||||
{t('paymentConfig.modal.save', 'Save fees')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -77,6 +77,7 @@ export function ProfilePage() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -156,8 +157,8 @@ export function ProfilePage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@@ -169,12 +170,10 @@ export function ProfilePage() {
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: strongPasswordSchema(12),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('profile.validation.passwordMismatch'),
|
||||
@@ -208,8 +207,8 @@ export function ProfilePage() {
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
@@ -413,12 +412,15 @@ export function ProfilePage() {
|
||||
{...registerPassword('oldPassword')}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
|
||||
</div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.confirmPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
|
||||
@@ -4,15 +4,12 @@ import {
|
||||
Title,
|
||||
Group,
|
||||
Button,
|
||||
Table,
|
||||
Badge,
|
||||
ActionIcon,
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
@@ -28,7 +25,7 @@ import {
|
||||
IconSend,
|
||||
IconGavel,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
@@ -95,7 +92,7 @@ function QuestionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
@@ -109,21 +106,23 @@ function QuestionForm({
|
||||
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetQuestionsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
@@ -145,6 +144,7 @@ export function QuestionPage() {
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||
const page = paginate(filtered);
|
||||
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
|
||||
@@ -211,14 +211,93 @@ export function QuestionPage() {
|
||||
notify.success(t('question.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Question>[] = [
|
||||
{
|
||||
header: t('question.columns.title'),
|
||||
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.columns.certification'),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.columns.form'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('question.columns.points'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.points}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('question.qc.column'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[row.original.status] ?? 'gray'} title={row.original.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('question.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
const q = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={isSubmittingReview}
|
||||
onClick={() => handleSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => openReview(q, 'APPROVED')}>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="red" onClick={() => openReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => openReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -240,98 +319,33 @@ export function QuestionPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('question.pool')}</Text>
|
||||
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
|
||||
<Select
|
||||
placeholder={t('question.filterByCertification')}
|
||||
data={[{ value: '', label: 'All' }, ...certOptions]}
|
||||
value={certFilter}
|
||||
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((q) => (
|
||||
<Table.Tr key={q.id}>
|
||||
<Table.Td><Text fz="sm" maw={300} lineClamp={2}>{q.title[locale]}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm">{getCertName(q.certificationId)}</Text></Table.Td>
|
||||
<Table.Td><Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q.points}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={isSubmittingReview}
|
||||
onClick={() => handleSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => openReview(q, 'APPROVED')}
|
||||
>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => openReview(q, 'REJECTED')}
|
||||
>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => openReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => { setEditing(q); setShowForm(true); }}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => { setDeleteTarget(q); openDelete(); }}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={6}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('question.noQuestions')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
@@ -367,10 +381,10 @@ export function QuestionPage() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -43,6 +43,7 @@ export function RecordResultModal({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<Record<string, number>>({});
|
||||
@@ -227,12 +228,12 @@ export function RecordResultModal({
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||
{t('result.saveResult')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -18,6 +18,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetPendingAppealsQuery,
|
||||
@@ -35,6 +36,7 @@ import type { ExamAppeal } from '../types/result';
|
||||
export function ExamAppealsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||
|
||||
@@ -128,7 +130,7 @@ export function ExamAppealsPage() {
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
|
||||
<Text fz="xs">{showDate(appeal.createdAt)}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
@@ -36,9 +37,10 @@ import {
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetResultsQuery,
|
||||
useLazyGetResultQuery,
|
||||
@@ -115,9 +117,13 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
|
||||
export function ResultPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetResultsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
@@ -147,7 +153,7 @@ export function ResultPage() {
|
||||
const [qcRemark, setQcRemark] = useState('');
|
||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${localized(e.title)} (${e.date})` }));
|
||||
|
||||
const startRecord = () => {
|
||||
const ex = exams.find((e) => e.id === pickerExamId);
|
||||
@@ -284,14 +290,107 @@ export function ResultPage() {
|
||||
notify.success(t('result.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('result.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Result>[] = [
|
||||
{
|
||||
header: t('result.columns.seafarer'),
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" fw={500}>
|
||||
{row.original.seafarer
|
||||
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
|
||||
: row.original.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.exam'),
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.totalScore'),
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('result.columns.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.review.column'),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${row.original.reviewStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('result.columns.date'),
|
||||
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('result.columns.actions', 'Actions'),
|
||||
cell: ({ row }) => {
|
||||
const r = row.original;
|
||||
return (
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="yellow" onClick={() => openQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => openQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => openQc(r, 'return')}>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => viewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const page = paginate(filtered);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -324,7 +423,7 @@ export function ResultPage() {
|
||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('result.section')}</Text>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
@@ -332,7 +431,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.seafarer')}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
@@ -340,7 +439,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.filterByExam')}
|
||||
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
||||
value={examFilter}
|
||||
onChange={(v) => setExamFilter(v ?? null)}
|
||||
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
@@ -348,113 +447,20 @@ export function ResultPage() {
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
||||
<Table.Th>{t('result.review.column')}</Table.Th>
|
||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{filtered.map((r) => (
|
||||
<Table.Tr key={r.id}>
|
||||
<Table.Td>
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{r.exam ? r.exam.title[locale] : getExamTitle(r.examId)}</Text></Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{r.totalScore}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => openQc(r, 'moderate')}
|
||||
>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => openQc(r, 'approve')}
|
||||
>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => openQc(r, 'return')}
|
||||
>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => viewDetail(r)}
|
||||
>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => { setDeleteTarget(r); openDelete(); }}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
{filtered.length === 0 && (
|
||||
<Table.Tr>
|
||||
<Table.Td colSpan={7}>
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noItems')}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
)}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Paper>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
@@ -478,7 +484,7 @@ export function ResultPage() {
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
|
||||
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
|
||||
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
|
||||
<InfoRow label={t('result.detail.dateOfBirth')} value={showDate(detailResult.profile?.dob)} />
|
||||
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
@@ -496,7 +502,7 @@ export function ResultPage() {
|
||||
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
|
||||
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
|
||||
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
|
||||
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
|
||||
<InfoRow label={t('result.detail.date')} value={showDate(detailResult.exam.date)} />
|
||||
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
@@ -592,7 +598,7 @@ export function ResultPage() {
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
@@ -602,7 +608,7 @@ export function ResultPage() {
|
||||
>
|
||||
{t('result.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
|
||||
@@ -658,10 +664,10 @@ export function ResultPage() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Choose exam, then record */}
|
||||
@@ -678,10 +684,10 @@ export function ResultPage() {
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
|
||||
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
@@ -25,7 +25,8 @@ import {
|
||||
IconShieldCog,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
@@ -77,11 +78,13 @@ function SeafarerDetailDrawer({
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -102,7 +105,7 @@ function SeafarerDetailDrawer({
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer number
|
||||
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
@@ -110,41 +113,49 @@ function SeafarerDetailDrawer({
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
{t('seafarerRegistry.drawer.department', 'Department')}
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Status
|
||||
{t('seafarerRegistry.drawer.status', 'Status')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus ?? 'NOT REGISTERED'}
|
||||
{profile.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: t('seafarerRegistry.drawer.notRegistered', 'NOT REGISTERED')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Status reason: {profile.seafarerStatusReason}
|
||||
{t('seafarerRegistry.drawer.statusReason', {
|
||||
reason: profile.seafarerStatusReason,
|
||||
defaultValue: 'Status reason: {{reason}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
Sea Service
|
||||
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
Medical
|
||||
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
@@ -153,16 +164,16 @@ function SeafarerDetailDrawer({
|
||||
<Loader size="sm" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No sea-service records.
|
||||
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>Period</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -172,17 +183,20 @@ function SeafarerDetailDrawer({
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
{t('seafarerRegistry.drawer.imoPrefix', {
|
||||
number: record.imoNumber,
|
||||
defaultValue: 'IMO {{number}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
{showDate(record.engagementDate)} → {showDate(record.dischargeDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -197,16 +211,16 @@ function SeafarerDetailDrawer({
|
||||
<Loader size="sm" />
|
||||
) : (medical ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
No medical certificates.
|
||||
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Validity</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
@@ -214,7 +228,7 @@ function SeafarerDetailDrawer({
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||
<Table.Td>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
{showDate(certificate.issueDate)} → {showDate(certificate.expiryDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -222,7 +236,7 @@ function SeafarerDetailDrawer({
|
||||
size="sm"
|
||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||
>
|
||||
{certificate.status}
|
||||
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
@@ -248,6 +262,7 @@ function StatusModal({
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
@@ -260,11 +275,13 @@ function StatusModal({
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Seafarer status updated');
|
||||
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the status'));
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('seafarerRegistry.modal.updateFailed', 'Could not update the status')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -272,27 +289,33 @@ function StatusModal({
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title="Change seafarer status"
|
||||
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
||||
reason is recorded and visible to the seafarer.
|
||||
{t('seafarerRegistry.modal.body', {
|
||||
number: profile?.seafarerNumber,
|
||||
status: profile?.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: profile?.seafarerStatus,
|
||||
defaultValue:
|
||||
'{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
||||
})}
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
label={t('seafarerRegistry.modal.newStatus', 'New status')}
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'INACTIVE', label: 'Close' },
|
||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
||||
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
|
||||
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
|
||||
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
label={t('seafarerRegistry.modal.reason', 'Reason')}
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
@@ -300,7 +323,7 @@ function StatusModal({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
{t('seafarerRegistry.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
@@ -308,7 +331,7 @@ function StatusModal({
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
{t('seafarerRegistry.modal.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -324,6 +347,7 @@ function StatusModal({
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
@@ -335,6 +359,7 @@ export function SeafarerRegistryPage() {
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
@@ -349,18 +374,95 @@ export function SeafarerRegistryPage() {
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
const page = paginate(items);
|
||||
|
||||
const columns: AdvancedColumn<ProfileRow>[] = [
|
||||
{
|
||||
header: t('seafarerRegistry.columns.name', 'Name'),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setDetail(row.original)}
|
||||
>
|
||||
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
|
||||
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.department', 'Department'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{row.original.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.phone', 'Phone'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('seafarerRegistry.columns.status', 'Status'),
|
||||
cell: ({ row }) =>
|
||||
row.original.seafarerNumber ? (
|
||||
<Badge size="sm" variant="light" color={SEAFARER_STATUS_COLORS[row.original.seafarerStatus ?? ''] ?? 'gray'}>
|
||||
{t(`seafarerRegistry.status.${row.original.seafarerStatus}`, row.original.seafarerStatus ?? '')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
||||
{row.original.isComplete
|
||||
? t('seafarerRegistry.notRegistered', 'Not registered')
|
||||
: t('seafarerRegistry.incomplete', 'Incomplete')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('seafarerRegistry.columns.actions', 'Actions'),
|
||||
cell: ({ row }) =>
|
||||
row.original.seafarerNumber ? (
|
||||
<Tooltip label={t('seafarerRegistry.statusActionTooltip', 'Suspend / reinstate / close')}>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setStatusTarget(row.original)}
|
||||
>
|
||||
{t('seafarerRegistry.statusAction', 'Status')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
{t('seafarerRegistry.profileCount', {
|
||||
count: data?.total ?? 0,
|
||||
defaultValue: '{{count}} profile(s)',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, ID or seafarer number"
|
||||
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
@@ -369,97 +471,23 @@ export function SeafarerRegistryPage() {
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
{isLoading ? (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Center h={160}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
||||
</Text>
|
||||
</Center>
|
||||
) : (
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Name</Table.Th>
|
||||
<Table.Th>Seafarer №</Table.Th>
|
||||
<Table.Th>Department</Table.Th>
|
||||
<Table.Th>ID number</Table.Th>
|
||||
<Table.Th>Phone</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((p) => (
|
||||
<Table.Tr
|
||||
key={p.id}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => setDetail(p)}
|
||||
>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" ff="monospace">
|
||||
{p.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{p.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{p.seafarerNumber ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
|
||||
>
|
||||
{p.seafarerStatus}
|
||||
</Badge>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Not registered' : 'Incomplete'}
|
||||
</Badge>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td onClick={(e) => e.stopPropagation()}>
|
||||
{p.seafarerNumber && (
|
||||
<Tooltip label="Suspend / reinstate / close">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => setStatusTarget(p)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
)}
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading}
|
||||
emptyText={
|
||||
search
|
||||
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
|
||||
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -138,8 +139,8 @@ export default function UserManagementPage() {
|
||||
style.textContent = UM_OVERRIDES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
|
||||
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
|
||||
|
||||
const session: UserManagementSessionOptions = {
|
||||
initialSession: token
|
||||
|
||||
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal file
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||
|
||||
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
|
||||
|
||||
export type RegistrationStatus =
|
||||
| 'Pending'
|
||||
| 'Under Review'
|
||||
| 'Correction Required'
|
||||
| 'Resubmitted'
|
||||
| 'Approved'
|
||||
| 'Rejected';
|
||||
|
||||
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'blue',
|
||||
'Correction Required': 'orange',
|
||||
Resubmitted: 'cyan',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
|
||||
|
||||
export const RENEWAL_COLOR: Record<RenewalState, string> = {
|
||||
OK: 'gray',
|
||||
'Due Soon': 'orange',
|
||||
Overdue: 'red',
|
||||
};
|
||||
|
||||
export interface RegistrationOwner {
|
||||
name: string;
|
||||
idOrTin: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface TimelineStep {
|
||||
date: string | null;
|
||||
event: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface RegistrationCertificate {
|
||||
name: string;
|
||||
number: string;
|
||||
issueDate: string;
|
||||
}
|
||||
|
||||
export interface RegistrationDocument {
|
||||
key: string;
|
||||
label: string;
|
||||
fileName: string;
|
||||
fileType: 'pdf' | 'image';
|
||||
}
|
||||
|
||||
export interface VesselRegistration {
|
||||
id: string;
|
||||
category: VesselCategory;
|
||||
status: RegistrationStatus;
|
||||
submitted: string;
|
||||
remarks?: string;
|
||||
correctionFields?: string[];
|
||||
expiryDate?: string;
|
||||
renewal?: RenewalState;
|
||||
timeline: TimelineStep[];
|
||||
documents: RegistrationDocument[];
|
||||
certificates?: RegistrationCertificate[];
|
||||
|
||||
// Vessel details
|
||||
vesselName: string;
|
||||
vesselType: string;
|
||||
registrationArea: string;
|
||||
flagState: string;
|
||||
passengerCapacity?: string;
|
||||
grossTonnage?: string;
|
||||
length: string;
|
||||
breadth: string;
|
||||
depth: string;
|
||||
|
||||
// Technical
|
||||
imoNumber?: string;
|
||||
hullNumber?: string;
|
||||
shipyard: string;
|
||||
yearBuilt: string;
|
||||
engineType: string;
|
||||
engineNumber: string;
|
||||
enginePower: string;
|
||||
hullMaterial: string;
|
||||
|
||||
// Ownership
|
||||
owner: RegistrationOwner;
|
||||
}
|
||||
|
||||
export const CERTIFICATES: Record<VesselCategory, string[]> = {
|
||||
'Inland Waterway': ['Inland Vessel Registration Certificate'],
|
||||
'Sea-going': [
|
||||
'Certificate of Nationality',
|
||||
'Certificate of Ownership',
|
||||
'Certificate of Registration',
|
||||
'Minimum Safe Manning Certificate',
|
||||
],
|
||||
};
|
||||
|
||||
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
|
||||
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
|
||||
|
||||
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
|
||||
{
|
||||
id: 'VR-2025-0001',
|
||||
category: 'Sea-going',
|
||||
status: 'Under Review',
|
||||
submitted: '2025-06-20',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-20'),
|
||||
{ date: '2025-06-22', event: 'Document Verification', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'nile_star_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'nile_star_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'nile_star_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'nile_star_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
vesselName: 'MV Nile Star',
|
||||
vesselType: 'Bulk Carrier',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '18500',
|
||||
length: '190',
|
||||
breadth: '28',
|
||||
depth: '15',
|
||||
imoNumber: 'IMO9876543',
|
||||
shipyard: 'Hyundai Heavy Industries',
|
||||
yearBuilt: '2016',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-44210',
|
||||
enginePower: '12000 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Nile Shipping PLC',
|
||||
idOrTin: 'TIN-0012345678',
|
||||
phone: '+251911223344',
|
||||
email: 'ops@nileshipping.et',
|
||||
address: 'Bole Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0002',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Correction Required',
|
||||
submitted: '2025-06-10',
|
||||
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
|
||||
correctionFields: ['Vessel Photos'],
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-10'),
|
||||
{ date: '2025-06-12', event: 'Document Verification', done: true },
|
||||
{ date: '2025-06-14', event: 'Correction Requested', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'tana_ferry3_photos.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Tana Ferry 3',
|
||||
vesselType: 'Ferry',
|
||||
registrationArea: 'Lake Tana',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '40',
|
||||
length: '18',
|
||||
breadth: '5',
|
||||
depth: '2',
|
||||
hullNumber: 'HN-2211',
|
||||
shipyard: 'Bahir Dar Boat Works',
|
||||
yearBuilt: '2020',
|
||||
engineType: 'Outboard',
|
||||
engineNumber: 'ENG-9931',
|
||||
enginePower: '150 hp',
|
||||
hullMaterial: 'Fiberglass',
|
||||
owner: {
|
||||
name: 'Getachew Alemu',
|
||||
idOrTin: 'ID-4455667788',
|
||||
phone: '+251922334455',
|
||||
email: 'getachew.alemu@example.com',
|
||||
address: 'Bahir Dar, Amhara',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0003',
|
||||
category: 'Sea-going',
|
||||
status: 'Approved',
|
||||
submitted: '2025-04-05',
|
||||
expiryDate: '2026-08-15',
|
||||
renewal: 'Due Soon',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-04-05'),
|
||||
{ date: '2025-04-08', event: 'Document Verification', done: true },
|
||||
{ date: '2025-04-20', event: 'Inspection', done: true },
|
||||
{ date: '2025-04-28', event: 'Approval', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'abay_voyager_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'abay_voyager_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'abay_voyager_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'abay_voyager_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
certificates: [
|
||||
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28' },
|
||||
],
|
||||
vesselName: 'MV Abay Voyager',
|
||||
vesselType: 'General Cargo',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '9600',
|
||||
length: '140',
|
||||
breadth: '21',
|
||||
depth: '11',
|
||||
imoNumber: 'IMO9123456',
|
||||
shipyard: 'Damen Shipyards',
|
||||
yearBuilt: '2012',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-33012',
|
||||
enginePower: '7200 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Abay Maritime PLC',
|
||||
idOrTin: 'TIN-0098765432',
|
||||
phone: '+251933445566',
|
||||
email: 'contact@abaymaritime.et',
|
||||
address: 'Kirkos Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0004',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Rejected',
|
||||
submitted: '2025-03-02',
|
||||
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-03-02'),
|
||||
{ date: '2025-03-05', event: 'Document Verification', done: true },
|
||||
{ date: '2025-03-14', event: 'Inspection', done: true },
|
||||
{ date: '2025-03-18', event: 'Rejected', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'awash_cargo1_photos.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Awash Cargo 1',
|
||||
vesselType: 'Cargo Barge',
|
||||
registrationArea: 'Awash River Basin',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '0',
|
||||
length: '22',
|
||||
breadth: '6',
|
||||
depth: '3',
|
||||
hullNumber: 'HN-1187',
|
||||
shipyard: 'Awash River Works',
|
||||
yearBuilt: '2018',
|
||||
engineType: 'Inboard',
|
||||
engineNumber: 'ENG-5567',
|
||||
enginePower: '210 hp',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Selam Tesfaye',
|
||||
idOrTin: 'ID-2233445566',
|
||||
phone: '+251944556677',
|
||||
email: 'selam.tesfaye@example.com',
|
||||
address: 'Adama, Oromia',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0005',
|
||||
category: 'Sea-going',
|
||||
status: 'Pending',
|
||||
submitted: '2025-07-10',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-07-10'),
|
||||
PENDING_STEP('Document Verification'),
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'genale_pearl_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'genale_pearl_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'genale_pearl_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'genale_pearl_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
vesselName: 'MV Genale Pearl',
|
||||
vesselType: 'Container Ship',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '24300',
|
||||
length: '210',
|
||||
breadth: '30',
|
||||
depth: '17',
|
||||
imoNumber: 'IMO9345678',
|
||||
shipyard: 'Samsung Heavy Industries',
|
||||
yearBuilt: '2019',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-51290',
|
||||
enginePower: '15400 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Genale Maritime PLC',
|
||||
idOrTin: 'TIN-0011223344',
|
||||
phone: '+251911998877',
|
||||
email: 'ops@genalemaritime.et',
|
||||
address: 'Kirkos Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0006',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Resubmitted',
|
||||
submitted: '2025-06-01',
|
||||
remarks: 'Re-uploaded clearer hull and deck photos as requested.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-01'),
|
||||
{ date: '2025-06-03', event: 'Document Verification', done: true },
|
||||
{ date: '2025-06-05', event: 'Correction Requested', done: true },
|
||||
{ date: '2025-06-18', event: 'Resubmitted', done: true },
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'zeway_runner_photos_v2.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Zeway Runner',
|
||||
vesselType: 'Passenger Boat',
|
||||
registrationArea: 'Lake Ziway',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '25',
|
||||
length: '14',
|
||||
breadth: '4',
|
||||
depth: '1.6',
|
||||
hullNumber: 'HN-3092',
|
||||
shipyard: 'Ziway Boat Works',
|
||||
yearBuilt: '2021',
|
||||
engineType: 'Outboard',
|
||||
engineNumber: 'ENG-7712',
|
||||
enginePower: '90 hp',
|
||||
hullMaterial: 'Fiberglass',
|
||||
owner: {
|
||||
name: 'Mekdes Yohannes',
|
||||
idOrTin: 'ID-5566778899',
|
||||
phone: '+251955667788',
|
||||
email: 'mekdes.y@example.com',
|
||||
address: 'Ziway, Oromia',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0007',
|
||||
category: 'Sea-going',
|
||||
status: 'Approved',
|
||||
submitted: '2024-08-01',
|
||||
expiryDate: '2025-08-01',
|
||||
renewal: 'Overdue',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2024-08-01'),
|
||||
{ date: '2024-08-05', event: 'Document Verification', done: true },
|
||||
{ date: '2024-08-18', event: 'Inspection', done: true },
|
||||
{ date: '2024-08-25', event: 'Approval', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'red_sea_trader_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'red_sea_trader_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'red_sea_trader_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'red_sea_trader_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
certificates: [
|
||||
{ name: 'Certificate of Nationality', number: 'CN-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Certificate of Ownership', number: 'CO-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Certificate of Registration', number: 'CR-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2024-0058', issueDate: '2024-08-25' },
|
||||
],
|
||||
vesselName: 'MV Red Sea Trader',
|
||||
vesselType: 'Tanker',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '31200',
|
||||
length: '228',
|
||||
breadth: '32',
|
||||
depth: '19',
|
||||
imoNumber: 'IMO9456789',
|
||||
shipyard: 'Mitsubishi Heavy Industries',
|
||||
yearBuilt: '2009',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-28871',
|
||||
enginePower: '18900 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Red Sea Tankers PLC',
|
||||
idOrTin: 'TIN-0055443322',
|
||||
phone: '+251911332211',
|
||||
email: 'fleet@redseatankers.et',
|
||||
address: 'Yeka Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ponytail: mutate mock, no live clock — swap for real API mutation when backend lands.
|
||||
export function applyDecision(
|
||||
reg: VesselRegistration,
|
||||
status: RegistrationStatus,
|
||||
remarks?: string,
|
||||
correctionFields?: string[]
|
||||
): void {
|
||||
reg.status = status;
|
||||
if (remarks) reg.remarks = remarks;
|
||||
reg.correctionFields = status === 'Correction Required' ? correctionFields : undefined;
|
||||
const eventLabel: Record<RegistrationStatus, string> = {
|
||||
Pending: 'Pending',
|
||||
'Under Review': 'Marked Under Review',
|
||||
'Correction Required': 'Correction Requested',
|
||||
Resubmitted: 'Resubmitted',
|
||||
Approved: 'Approval',
|
||||
Rejected: 'Rejected',
|
||||
};
|
||||
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
|
||||
reg.timeline.push({ date: today, event: eventLabel[status], done: true });
|
||||
}
|
||||
|
||||
export function generateCertificates(reg: VesselRegistration): void {
|
||||
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
|
||||
reg.certificates = CERTIFICATES[reg.category].map((name, i) => ({
|
||||
name,
|
||||
number: `${name.split(' ').map((w) => w[0]).join('').toUpperCase()}-2025-${String(1000 + i)}`,
|
||||
issueDate: today ?? reg.submitted,
|
||||
}));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer queue"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferQueuePage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferReviewPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer review"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferReviewPage;
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
|
||||
/>
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
|
||||
<Button color="teal" onClick={saveField}>
|
||||
{isNew ? 'Add Field' : 'Save Changes'}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -27,6 +27,7 @@ import {
|
||||
IconShieldCog,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetVesselIncidentsQuery,
|
||||
@@ -56,6 +57,7 @@ function VesselDetailDrawer({
|
||||
}) {
|
||||
const { data: incidents, isLoading: loadingIncidents } =
|
||||
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const particulars: [string, string | number | null][] = vessel
|
||||
? [
|
||||
@@ -75,7 +77,7 @@ function VesselDetailDrawer({
|
||||
['Engines', vessel.numberOfEngines],
|
||||
['Hull material', vessel.hullMaterial],
|
||||
['Owner', vessel.ownerName],
|
||||
['Registered', vessel.registeredAt?.slice(0, 10) ?? null],
|
||||
['Registered', vessel.registeredAt ? showDate(vessel.registeredAt) : null],
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -127,7 +129,7 @@ function VesselDetailDrawer({
|
||||
<Card key={incident.id} withBorder radius="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{incident.occurredAt}
|
||||
{showDate(incident.occurredAt)}
|
||||
{incident.location ? ` — ${incident.location}` : ''}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
@@ -240,6 +242,7 @@ export function VesselRegistrationQueuePage() {
|
||||
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -319,7 +322,7 @@ export function VesselRegistrationQueuePage() {
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{vessel.registeredAt?.slice(0, 10)}
|
||||
{showDate(vessel.registeredAt)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@ export const en = {
|
||||
tagline: 'Control Center',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
validationError: 'Please check your input and try again.',
|
||||
authError: 'Your session has expired. Please sign in again.',
|
||||
permissionError: "You don't have permission to perform this action.",
|
||||
notFoundError: 'The requested item was not found.',
|
||||
fileTooLarge: 'The file is too large to upload.',
|
||||
networkError: 'Network error. Check your connection and try again.',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
@@ -23,6 +34,12 @@ export const en = {
|
||||
typeCombined: 'Combined SA + FF',
|
||||
typeJointInvestment: 'Joint Investment',
|
||||
typeMto: 'Multimodal Transport Operator',
|
||||
typeSEAFARER_REGISTRATION: 'Seafarer Registration',
|
||||
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
||||
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
||||
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
||||
typeENDORSEMENT_COC: 'CoC Endorsement',
|
||||
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
||||
primary: 'Primary',
|
||||
destinations: 'Go to',
|
||||
noResults: 'Nothing found',
|
||||
@@ -43,6 +60,7 @@ export const en = {
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
vesselRegistrationReport: 'Vessel Registration Report',
|
||||
@@ -56,13 +74,18 @@ export const en = {
|
||||
waiver: 'Waiver',
|
||||
preWaiverQueue: 'Pre-Waiver Queue',
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
analytics: 'Analytics',
|
||||
medicalVerification: 'Medical Verification',
|
||||
medicalVerification: 'Medical and Sea Service Verification',
|
||||
locations: 'Locations',
|
||||
configuration: 'Configuration',
|
||||
profile: 'Profile',
|
||||
@@ -86,6 +109,12 @@ export const en = {
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
toggleTheme: 'Toggle light / dark mode',
|
||||
refresh: 'Refresh',
|
||||
view: 'View',
|
||||
toggleColumns: 'Toggle columns',
|
||||
noResult: 'No results',
|
||||
switchCalendar: 'Switch calendar type',
|
||||
time: 'Time',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
@@ -332,6 +361,11 @@ export const en = {
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
},
|
||||
|
||||
country: {
|
||||
select: 'Select a country',
|
||||
notFound: 'No countries found',
|
||||
},
|
||||
|
||||
location: {
|
||||
title: 'Locations',
|
||||
hierarchy: 'Location Hierarchy',
|
||||
@@ -753,6 +787,23 @@ export const en = {
|
||||
anyType: 'Any',
|
||||
typeCol: 'Type',
|
||||
statusCol: 'Status',
|
||||
statusValues: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
},
|
||||
submittedFrom: 'Submitted from',
|
||||
submittedTo: 'Submitted to',
|
||||
clearFilters: 'Clear',
|
||||
@@ -765,6 +816,7 @@ export const en = {
|
||||
compact: 'Compact',
|
||||
number: 'App #',
|
||||
company: 'Company',
|
||||
applicant: 'Applicant',
|
||||
tin: 'TIN',
|
||||
submitted: 'Submitted',
|
||||
sla: 'Age / SLA',
|
||||
@@ -960,6 +1012,42 @@ export const en = {
|
||||
noFile: 'No file',
|
||||
noFileUploaded: 'Nothing uploaded yet',
|
||||
noInlinePreview: 'This file type cannot be previewed in the browser.',
|
||||
previewFallback: 'document',
|
||||
},
|
||||
inspectionStatus: {
|
||||
SCHEDULED: 'Scheduled',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
},
|
||||
kindValues: {
|
||||
NEW: 'New',
|
||||
RENEWAL: 'Renewal',
|
||||
},
|
||||
checklist: {
|
||||
officePremises: 'Office premises',
|
||||
storageFacilities: 'Warehouse / storage facilities',
|
||||
vehiclesEquipment: 'Vehicles / equipment',
|
||||
safetyCompliance: 'Safety & regulatory compliance',
|
||||
},
|
||||
eligibilityRule: {
|
||||
capitalThreshold: 'Paid-up capital ≥ {{amount}}',
|
||||
notRecorded: 'Not recorded',
|
||||
declaredSuffix: ' (declared)',
|
||||
verifiedSuffix: ' (verified)',
|
||||
inspectionCompleted: 'Physical inspection completed',
|
||||
recorded: 'Recorded',
|
||||
notYetRecorded: 'Not yet recorded',
|
||||
},
|
||||
sla: {
|
||||
untracked: 'No turnaround target is set for this licence type.',
|
||||
target: 'Target {{hours}}h from submission ({{date}})',
|
||||
met: 'Met',
|
||||
missed: 'Missed',
|
||||
decidedIn: 'Decided in {{duration}}. {{target}}',
|
||||
overdue: 'Overdue {{duration}}',
|
||||
overdueBy: 'Overdue by {{duration}}. {{target}}',
|
||||
left: '{{duration}} left',
|
||||
remaining: '{{duration}} remaining. {{target}}',
|
||||
},
|
||||
done: {
|
||||
completeReview: 'Review completed',
|
||||
@@ -1031,6 +1119,162 @@ export const en = {
|
||||
noPermission: 'You do not have permission',
|
||||
noPublishPermission: 'You cannot publish designs',
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: 'Seafarer registry',
|
||||
profileCount_one: '{{count}} profile',
|
||||
profileCount_other: '{{count}} profiles',
|
||||
searchPlaceholder: 'Name, ID or seafarer number',
|
||||
emptySearch: 'No profiles match that search.',
|
||||
emptyNone: 'No seafarers registered yet.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
number: 'Seafarer №',
|
||||
department: 'Department',
|
||||
idNumber: 'ID number',
|
||||
phone: 'Phone',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
notRegistered: 'Not registered',
|
||||
incomplete: 'Incomplete',
|
||||
statusAction: 'Status',
|
||||
statusActionTooltip: 'Suspend / reinstate / close',
|
||||
status: {
|
||||
ACTIVE: 'Active',
|
||||
INACTIVE: 'Inactive',
|
||||
PENDING: 'Pending',
|
||||
SUSPENDED: 'Suspended',
|
||||
},
|
||||
departments: {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
},
|
||||
recordStatus: {
|
||||
SUBMITTED: 'Submitted',
|
||||
VERIFIED: 'Verified',
|
||||
REJECTED: 'Rejected',
|
||||
},
|
||||
drawer: {
|
||||
seafarerNumber: 'Seafarer number',
|
||||
department: 'Department',
|
||||
status: 'Status',
|
||||
notRegistered: 'NOT REGISTERED',
|
||||
statusReason: 'Status reason: {{reason}}',
|
||||
seaServiceTab: 'Sea Service',
|
||||
medicalTab: 'Medical',
|
||||
noSeaService: 'No sea-service records.',
|
||||
noMedical: 'No medical certificates.',
|
||||
vessel: 'Vessel',
|
||||
rank: 'Rank',
|
||||
period: 'Period',
|
||||
imoPrefix: 'IMO {{number}}',
|
||||
issuer: 'Issuer',
|
||||
validity: 'Validity',
|
||||
fitness: 'Fitness',
|
||||
},
|
||||
modal: {
|
||||
title: 'Change seafarer status',
|
||||
body: '{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
||||
newStatus: 'New status',
|
||||
suspend: 'Suspend',
|
||||
close: 'Close',
|
||||
reinstate: 'Reinstate',
|
||||
reason: 'Reason',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
updated: 'Seafarer status updated',
|
||||
updateFailed: 'Could not update the status',
|
||||
},
|
||||
},
|
||||
|
||||
paymentConfig: {
|
||||
title: 'Payment configuration',
|
||||
subtitle: 'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
changeNotice: 'A change applies to applications approved from now on. Anything already approved keeps the amount it was quoted, so an edit here can never alter what an applicant has already been asked to pay.',
|
||||
loadError: 'Could not load licence types',
|
||||
columns: {
|
||||
type: 'Licence type',
|
||||
newApplication: 'New application',
|
||||
renewal: 'Renewal',
|
||||
charged: 'Charged?',
|
||||
},
|
||||
renewalSameTooltip: 'No separate renewal fee — renewal is charged at the new-application rate',
|
||||
renewalSameAsNew: '(same as new)',
|
||||
chargedOnApproval: 'On approval',
|
||||
chargedNotChargedTooltip: 'This licence type ends with an EMA decision and never reaches a payment stage',
|
||||
chargedNotCharged: 'Not charged',
|
||||
edit: 'Edit',
|
||||
gateway: {
|
||||
title: 'Payment gateway',
|
||||
subtitle: 'Set by the payment service environment — not editable here.',
|
||||
provider: 'Telebirr',
|
||||
bypassTooltip: 'ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.',
|
||||
bypassEnabled: 'Test bypass enabled',
|
||||
bypassOff: 'Test bypass off',
|
||||
},
|
||||
modal: {
|
||||
title: 'Fees — {{type}}',
|
||||
noPaymentStage: 'This licence type concludes with an EMA decision and never reaches a payment stage, so a fee set here stays unused until that changes.',
|
||||
chargeableLabel: 'This licence carries a fee',
|
||||
chargeableDescription: 'Turn off for licence types applicants are never charged for.',
|
||||
newFeeLabel: 'New application fee',
|
||||
sameRateLabel: 'Charge renewal at the same rate',
|
||||
sameRateDescription: 'Turn off to set a separate renewal fee.',
|
||||
renewalFeeLabel: 'Renewal fee',
|
||||
currencyLabel: 'Currency',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save fees',
|
||||
missingNewFee: 'Enter a new-application fee, or turn off "carries a fee".',
|
||||
missingRenewalFee: 'Enter a renewal fee, or charge renewal at the same rate.',
|
||||
updated: '{{type}} fees updated.',
|
||||
saveFailed: 'Could not save the fees',
|
||||
},
|
||||
},
|
||||
|
||||
recordVerification: {
|
||||
title: 'Record verification',
|
||||
subtitle: 'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
tabs: {
|
||||
medical: 'Medical ({{count}})',
|
||||
seaService: 'Sea Service ({{count}})',
|
||||
},
|
||||
columns: {
|
||||
seafarer: 'Seafarer',
|
||||
issuer: 'Issuer',
|
||||
validity: 'Validity',
|
||||
fitness: 'Fitness',
|
||||
vessel: 'Vessel',
|
||||
rank: 'Rank',
|
||||
period: 'Period',
|
||||
actions: 'Actions',
|
||||
},
|
||||
fitness: {
|
||||
FIT: 'Fit',
|
||||
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
|
||||
UNFIT: 'Unfit',
|
||||
},
|
||||
evidence: 'Evidence',
|
||||
verify: 'Verify',
|
||||
reject: 'Reject',
|
||||
evidenceTitleMedical: 'Evidence for {{name}} Medical Certificate',
|
||||
evidenceTitleSeaService: 'Evidence for {{name}} Sea Service Record',
|
||||
certificateVerified: 'Certificate verified',
|
||||
seaServiceVerified: 'Sea-service record verified',
|
||||
certificateRejected: 'Certificate rejected',
|
||||
seaServiceRejected: 'Sea-service record rejected',
|
||||
rulingFailed: 'Could not record the ruling',
|
||||
emptyText: 'Nothing awaiting verification.',
|
||||
rejectMedicalTitle: 'Reject medical certificate',
|
||||
rejectSeaServiceTitle: 'Reject sea-service record',
|
||||
rejectReasonLabel: 'What must the seafarer fix?',
|
||||
cancel: 'Cancel',
|
||||
evidenceModalTitle: 'Evidence Attachments',
|
||||
noAttachments: 'No evidence attachments uploaded for this record.',
|
||||
view: 'View',
|
||||
noUrl: 'No URL',
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
@@ -76,12 +76,18 @@ export function BackofficeLayout() {
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const initials = displayName
|
||||
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '?';
|
||||
? displayName
|
||||
.split(/\s+/)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
dispatch(baseApi.util.resetApiState());
|
||||
navigate("/login");
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
@@ -89,7 +95,7 @@ export function BackofficeLayout() {
|
||||
// readable form of the path segment. Every crumb was previously labelled
|
||||
// "Dashboard", which made the trail useless.
|
||||
const crumbs = [
|
||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
||||
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||
...segments
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => path !== '/dashboard')
|
||||
@@ -123,27 +129,31 @@ export function BackofficeLayout() {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const isSidebar = layoutMode === 'sidebar';
|
||||
const isSidebar = layoutMode === "sidebar";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
||||
navbar={isSidebar ? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
} : undefined}
|
||||
navbar={
|
||||
isSidebar
|
||||
? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: "var(--mantine-color-body)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
||||
<AppHeader
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||
@@ -151,7 +161,7 @@ export function BackofficeLayout() {
|
||||
breadcrumbs={crumbs}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName || t('app.name')}
|
||||
userName={displayName || t("app.name")}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
/>
|
||||
@@ -183,10 +193,10 @@ export function BackofficeLayout() {
|
||||
<AppShell.Navbar
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'width 200ms ease',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
@@ -9,9 +10,9 @@ import {
|
||||
IconGauge,
|
||||
IconGavel,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconLayoutDashboard,
|
||||
IconListCheck,
|
||||
IconMapPin,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
IconRosetteDiscountCheck,
|
||||
@@ -109,17 +110,21 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||
@@ -138,7 +143,6 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupAdministration',
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||
{
|
||||
to: '/configuration',
|
||||
label: 'nav.configuration',
|
||||
|
||||
@@ -16,11 +16,8 @@ import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
|
||||
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
@@ -34,8 +31,6 @@ import { VesselRegistrationQueuePage } from '../features/vessel-registration/pag
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
||||
@@ -69,14 +64,14 @@ const router = createBrowserRouter([
|
||||
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
||||
// 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/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
|
||||
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
|
||||
// Endorsement review happens in the config-driven licence queue.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
@@ -88,10 +83,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
||||
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
||||
{ path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ 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 /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
authStorage,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
setToken,
|
||||
} from '@ema-platform/auth';
|
||||
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||
import { preferencesReducer } from './preferences.slice';
|
||||
|
||||
configureAuthStorage('ema-backoffice');
|
||||
configureAuthStorage('ema-backoffice', true);
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
@@ -36,7 +37,11 @@ export const store = configureStore({
|
||||
});
|
||||
|
||||
configureTokenRefresh({
|
||||
onTokenExpired: refreshAccessToken,
|
||||
onTokenExpired: async () => {
|
||||
const token = await refreshAccessToken();
|
||||
store.dispatch(setToken(token));
|
||||
return token;
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -5,6 +5,44 @@
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||
come from Mantine's dark palette so they track the active color scheme
|
||||
instead of a fixed gray.
|
||||
--------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] * {
|
||||
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Print — the review dossier.
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ActionIcon, Button, Popover, TextInput } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
|
||||
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { EthDateTime } from 'ethiopian-calendar-date-converter';
|
||||
import '@daypicker/react/dist/style.css';
|
||||
|
||||
const EC_MONTHS_AM = [
|
||||
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
|
||||
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
|
||||
];
|
||||
|
||||
function toAmharicDisplay(date: Date): string {
|
||||
try {
|
||||
const eth = EthDateTime.fromEuropeanDate(date);
|
||||
return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`;
|
||||
} catch {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
export function toEthiopicDateLabel(date: Date): string {
|
||||
try {
|
||||
const eth = EthDateTime.fromEuropeanDate(date);
|
||||
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
|
||||
} catch {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
export interface AmharicDatePickerProps {
|
||||
label?: string;
|
||||
value?: Date | null;
|
||||
onChange?: (date: Date | null) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function AmharicDatePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
}: AmharicDatePickerProps) {
|
||||
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>('AMH');
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
|
||||
const displayValue = value
|
||||
? calendarType === 'EN'
|
||||
? value.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: toAmharicDisplay(value)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={close}
|
||||
position="bottom"
|
||||
width="auto"
|
||||
trapFocus
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<TextInput
|
||||
label={label}
|
||||
required={required}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
placeholder={placeholder}
|
||||
onClick={toggle}
|
||||
leftSection={
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
|
||||
}}
|
||||
aria-label="Switch calendar type"
|
||||
>
|
||||
{calendarType}
|
||||
</Button>
|
||||
}
|
||||
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
|
||||
rightSection={
|
||||
<ActionIcon size="md" variant="transparent" onClick={toggle}>
|
||||
<IconCalendarEvent size={20} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="md">
|
||||
{calendarType === 'AMH' ? (
|
||||
<EthiopicDayPicker
|
||||
mode="single"
|
||||
selected={value ?? undefined}
|
||||
numerals="latn"
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ?? null);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GregorianDayPicker
|
||||
mode="single"
|
||||
selected={value ?? undefined}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ?? null);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -25,6 +25,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -33,6 +34,7 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const CERTIFICATE_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
@@ -72,6 +74,8 @@ export function CertificatesPage() {
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
@@ -187,7 +191,7 @@ export function CertificatesPage() {
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
@@ -243,9 +247,9 @@ export function CertificatesPage() {
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
||||
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
|
||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
@@ -11,7 +10,6 @@ import {
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
@@ -22,7 +20,6 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -30,9 +27,7 @@ import {
|
||||
IconClipboardList,
|
||||
IconClockHour4,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconRefresh,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||
@@ -42,16 +37,14 @@ import {
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useCreateApplicationMutation,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
|
||||
|
||||
/**
|
||||
* The applicant's home screen.
|
||||
@@ -82,14 +75,6 @@ function formatMoney(amount: string | number | null, currency: string): string {
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const displayName = useSelector(
|
||||
@@ -101,8 +86,7 @@ export function DashboardPage() {
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
||||
useGetCertificateUrlMutation();
|
||||
const [createApplication, { isLoading: isRenewing }] =
|
||||
useCreateApplicationMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
|
||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
||||
@@ -127,27 +111,6 @@ export function DashboardPage() {
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||
* application of kind RENEWAL, asking for that licence type's renewal
|
||||
* document set. `previousLicenseId` is what ties it to the certificate being
|
||||
* replaced, and what the API requires.
|
||||
*/
|
||||
async function renewLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'RENEWAL',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
@@ -321,7 +284,12 @@ function ActionRequired({
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" bg="orange.0">
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ backgroundColor: 'var(--mantine-color-orange-light)' }}
|
||||
>
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
||||
<IconAlertTriangle size={14} />
|
||||
@@ -334,7 +302,7 @@ function ActionRequired({
|
||||
{applications.map((app) => {
|
||||
const detail = detailFor(app);
|
||||
return (
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder bg="white">
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
@@ -489,6 +457,7 @@ function ApplicationTable({
|
||||
applications: LicenseApplication[];
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
return (
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
@@ -549,91 +518,6 @@ function ApplicationTable({
|
||||
);
|
||||
}
|
||||
|
||||
function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
isRenewing,
|
||||
onDownload,
|
||||
onRenew,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
isRenewing: boolean;
|
||||
onDownload: () => void;
|
||||
onRenew: () => void;
|
||||
}) {
|
||||
// The API computes both in the authority's timezone; the local fallbacks are
|
||||
// only for a cached response from before those fields existed.
|
||||
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
const renewable = license.renewable ?? false;
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(license.licenseType?.name) || 'Licence'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? 'Expired' : license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{expired ? 'Expired on' : 'Valid until'}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{formatDate(license.expiryDate)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label="Download certificate">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="lg"
|
||||
loading={isDownloading}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Renewal opens inside the licence type's window and stays open after
|
||||
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
||||
{renewable && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant={expired ? 'filled' : 'light'}
|
||||
color={expired ? 'orange' : undefined}
|
||||
loading={isRenewing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={onRenew}
|
||||
>
|
||||
{expired
|
||||
? 'Renew — this licence has expired'
|
||||
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The first-visit panel, in place of two empty sections saying the same thing.
|
||||
* It carries no call to action of its own — the catalogue is directly beneath
|
||||
|
||||
@@ -1,20 +1,254 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon
|
||||
color={ok ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
>
|
||||
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
|
||||
* two application entry points (CoC / GOC), and the seafarer's endorsement
|
||||
* applications and issued endorsements. The wizard itself is the
|
||||
* config-driven licensing flow.
|
||||
*/
|
||||
export function EndorsementPage() {
|
||||
const navigate = useNavigate();
|
||||
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
|
||||
const endorsementApplications = (applications?.items ?? []).filter((app) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
);
|
||||
const inFlight = endorsementApplications.filter(
|
||||
(app) => !TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const issued = (licenses?.items ?? []).filter((license) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
||||
);
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not fetch endorsement'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsements"
|
||||
description="Endorsements are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>My Endorsements</Title>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600} mb={6}>
|
||||
Eligibility
|
||||
</Text>
|
||||
<List spacing={4} size="sm">
|
||||
<EligibilityItem
|
||||
ok={registered}
|
||||
label={
|
||||
registered
|
||||
? `Registered seafarer (${profile?.seafarerNumber})`
|
||||
: 'Active seafarer registration required'
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
Endorse a CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
Endorse a GOC
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
Complete your{' '}
|
||||
<Text
|
||||
component="span"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
seafarer registration
|
||||
</Text>{' '}
|
||||
first — endorsement applications are refused without it.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Applications in progress</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Continue'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued endorsements</Title>
|
||||
{issued.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No endorsements issued yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate №</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{issued.map((license) => (
|
||||
<Table.Tr key={license.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
||||
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -14,11 +14,13 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
useApiQuery,
|
||||
useApiMutation,
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
interface OpenExam {
|
||||
@@ -81,6 +83,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
* when a mark looks wrong.
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
const [appealReason, setAppealReason] = useState('');
|
||||
|
||||
@@ -195,10 +199,10 @@ export function ExamsPage() {
|
||||
<Card key={exam.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{exam.title?.en}</Text>
|
||||
<Text fw={600}>{localized(exam.title)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{exam.certification?.name?.en ?? ''} ·{' '}
|
||||
{exam.date?.slice(0, 10)}
|
||||
{localized(exam.certification?.name)} ·{' '}
|
||||
{showDate(exam.date)}
|
||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -252,8 +256,8 @@ export function ExamsPage() {
|
||||
{registration.admissionNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{registration.exam?.date?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{localized(registration.exam?.title) || '—'}</Table.Td>
|
||||
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
|
||||
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
@@ -324,8 +328,8 @@ export function ExamsPage() {
|
||||
);
|
||||
return (
|
||||
<Table.Tr key={result.id}>
|
||||
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</Table.Td>
|
||||
<Table.Td>{localized(result.exam?.title) || '—'}</Table.Td>
|
||||
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{result.totalScore}
|
||||
@@ -374,7 +378,7 @@ export function ExamsPage() {
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Explain what you believe went wrong with the marking or the
|
||||
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
|
||||
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
|
||||
Appeals must be lodged within 14 days of publication.
|
||||
</Text>
|
||||
<Textarea
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
@@ -20,6 +23,60 @@ interface Props {
|
||||
disabled?: boolean;
|
||||
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
|
||||
errors?: Record<string, string>;
|
||||
/** The applicant's registered vessels, for the vessel-picker field. */
|
||||
vessels?: Vessel[];
|
||||
/**
|
||||
* Fired when the vessel picker's value resolves to a known vessel. Vessel
|
||||
* Information and Current Ownership are separate form sections, so this
|
||||
* component (one instance per section) can only fill its own fields —
|
||||
* the parent uses this to fill the rest via the same `VESSEL_FIELD_FILLERS`.
|
||||
*/
|
||||
onVesselSelected?: (vessel: Vessel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Vessel's own fields onto whichever fields the backend put in the
|
||||
* license form — same dual key/label matching the nationality prefill in
|
||||
* LicenseApplicationPage uses, so this doesn't depend on exact field-key
|
||||
* naming in the seeded formSchema. Exported so the parent page can apply it
|
||||
* across every section, not just the one the vessel picker lives in.
|
||||
*/
|
||||
export const VESSEL_FIELD_FILLERS: {
|
||||
matches: (label: string, key: string) => boolean;
|
||||
value: (vessel: Vessel) => unknown;
|
||||
}[] = [
|
||||
{ matches: (l, k) => k === 'registrationNumber' || l.includes('registration number'), value: (v) => v.registrationNumber },
|
||||
{ matches: (l, k) => k === 'vesselName' || l.includes('vessel name'), value: (v) => v.name },
|
||||
{ matches: (l, k) => k === 'category' || k === 'vesselCategory' || l.includes('vessel category'), value: (v) => v.category },
|
||||
{ matches: (l, k) => k === 'vesselType' || l.includes('vessel type'), value: (v) => v.vesselType },
|
||||
{ matches: (l, k) => k === 'imoNumber' || l.includes('imo'), value: (v) => v.imoNumber },
|
||||
{ matches: (l, k) => k === 'hullNumber' || l.includes('hull number'), value: (v) => v.hullNumber },
|
||||
{ matches: (l, k) => k === 'flagState' || l.includes('flag state') || l.includes('flag'), value: (v) => v.flagState },
|
||||
{ matches: (l, k) => k === 'portOfRegistry' || l.includes('port of registry'), value: (v) => v.portOfRegistry },
|
||||
{ matches: (l, k) => k === 'grossTonnage' || l.includes('gross tonnage'), value: (v) => v.grossTonnage },
|
||||
{ matches: (l, k) => k === 'passengerCapacity' || l.includes('passenger capacity'), value: (v) => v.passengerCapacity },
|
||||
{ matches: (l, k) => k === 'lengthMeters' || l.includes('length'), value: (v) => v.lengthMeters },
|
||||
{ matches: (l, k) => k === 'yearBuilt' || l.includes('year built'), value: (v) => v.yearBuilt },
|
||||
{ matches: (l, k) => k === 'engineType' || l.includes('engine type'), value: (v) => v.engineType },
|
||||
{ matches: (l, k) => k === 'enginePowerKw' || l.includes('engine power'), value: (v) => v.enginePowerKw },
|
||||
{ matches: (l, k) => k === 'numberOfEngines' || l.includes('number of engines'), value: (v) => v.numberOfEngines },
|
||||
{ matches: (l, k) => k === 'hullMaterial' || l.includes('hull material'), value: (v) => v.hullMaterial },
|
||||
// Current Ownership: only the "current" owner, never "new owner" —
|
||||
// that's who the vessel is being transferred to, not on file anywhere.
|
||||
{ matches: (l, k) => k === 'currentOwnerName' || k === 'currentOwner' || l.includes('current owner'), value: (v) => v.ownerName },
|
||||
];
|
||||
|
||||
export function fillFromVessel(
|
||||
vessel: Vessel,
|
||||
fields: FormFieldConfig[],
|
||||
onChange: (key: string, value: unknown) => void,
|
||||
) {
|
||||
for (const f of fields) {
|
||||
// English-pinned: matched against the English strings in VESSEL_FIELD_FILLERS.
|
||||
const label = (f.label.en ?? '').toLowerCase();
|
||||
const filler = VESSEL_FIELD_FILLERS.find((m) => m.matches(label, f.key));
|
||||
if (filler) onChange(f.key, filler.value(vessel) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +93,10 @@ export function ConfigDrivenSection({
|
||||
onChange,
|
||||
disabled,
|
||||
errors = {},
|
||||
vessels = [],
|
||||
onVesselSelected,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const fields = [...(section.fields ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
@@ -47,6 +107,8 @@ export function ConfigDrivenSection({
|
||||
if (!conditionHolds(field.showWhen, formData)) return null;
|
||||
|
||||
const label = localized(field.label);
|
||||
// English-pinned: the picker overrides below match English labels.
|
||||
const labelEn = (field.label.en ?? '').toLowerCase();
|
||||
const value = values?.[field.key];
|
||||
const error = errors[`${section.key}.${field.key}`];
|
||||
const common = {
|
||||
@@ -58,10 +120,41 @@ export function ConfigDrivenSection({
|
||||
disabled: disabled || field.readOnly,
|
||||
};
|
||||
const span = field.type === 'TEXTAREA' ? 12 : 6;
|
||||
// Same field the profile Address tab collects — give it the same
|
||||
// searchable, flag-labeled picker instead of a plain option list.
|
||||
const isNationality = field.key === 'nationality' || labelEn.includes('nationality');
|
||||
// Options here can't be seeded statically — they're the applicant's
|
||||
// own vessel register, so this overrides whatever type the backend
|
||||
// configured, the same way nationality overrides SELECT above.
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
{field.type === 'SELECT' ? (
|
||||
{isNationality ? (
|
||||
<CountrySelect
|
||||
{...common}
|
||||
demonym
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : isVesselPicker ? (
|
||||
<Select
|
||||
{...common}
|
||||
placeholder="Select a registered vessel"
|
||||
data={vessels.map((v) => ({ value: v.id, label: `${v.name} — ${v.registrationNumber}` }))}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => {
|
||||
onChange(field.key, v);
|
||||
const vessel = vessels.find((x) => x.id === v);
|
||||
if (vessel) {
|
||||
fillFromVessel(vessel, fields, onChange);
|
||||
onVesselSelected?.(vessel);
|
||||
}
|
||||
}}
|
||||
searchable
|
||||
clearable={!field.required}
|
||||
/>
|
||||
) : field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
@@ -93,11 +186,16 @@ export function ConfigDrivenSection({
|
||||
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
|
||||
/>
|
||||
) : field.type === 'DATE' ? (
|
||||
<TextInput
|
||||
{...common}
|
||||
type="date"
|
||||
// AmharicDatePicker has no `description`/`withAsterisk` props (from
|
||||
// `common`) — pass `required` explicitly so the asterisk still shows.
|
||||
<AmharicDatePicker
|
||||
label={label}
|
||||
error={error}
|
||||
disabled={common.disabled}
|
||||
required={field.required}
|
||||
dateFormat="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'TEXTAREA' ? (
|
||||
<Textarea
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface Props {
|
||||
requirements: DocumentRequirement[];
|
||||
attachments: Attachment[];
|
||||
@@ -56,6 +58,7 @@ export function DocumentSlots({
|
||||
onUploaded,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
@@ -63,11 +66,17 @@ export function DocumentSlots({
|
||||
const required = requirements.filter(
|
||||
(r) =>
|
||||
r.mode === 'ALWAYS' ||
|
||||
r.mode === 'OPTIONAL' ||
|
||||
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
|
||||
);
|
||||
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
|
||||
resetRefs.current[documentKey]?.();
|
||||
return;
|
||||
}
|
||||
setBusy(documentKey);
|
||||
setError(null);
|
||||
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
|
||||
@@ -92,7 +101,7 @@ export function DocumentSlots({
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={requirement.key}
|
||||
key={requirement.id}
|
||||
withBorder
|
||||
padding="md"
|
||||
style={{
|
||||
@@ -115,6 +124,11 @@ export function DocumentSlots({
|
||||
conditional
|
||||
</Badge>
|
||||
)}
|
||||
{requirement.mode === 'OPTIONAL' && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
optional
|
||||
</Badge>
|
||||
)}
|
||||
{uploaded && !flagRemark && (
|
||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
uploaded
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useCreateApplicationMutation,
|
||||
type IssuedLicense,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||
* application of kind RENEWAL, asking for that licence type's renewal
|
||||
* document set. `previousLicenseId` is what ties it to the certificate being
|
||||
* replaced, and what the API requires.
|
||||
*
|
||||
* Shared by the dashboard and My Applications so both offer the same renew
|
||||
* action instead of drifting.
|
||||
*/
|
||||
export function useRenewLicense() {
|
||||
const navigate = useNavigate();
|
||||
const [createApplication, { isLoading: isRenewing }] =
|
||||
useCreateApplicationMutation();
|
||||
|
||||
async function renewLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'RENEWAL',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||
}
|
||||
}
|
||||
|
||||
return { renewLicense, isRenewing };
|
||||
}
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
export function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
isRenewing,
|
||||
onDownload,
|
||||
onRenew,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
isRenewing: boolean;
|
||||
onDownload: () => void;
|
||||
onRenew: () => void;
|
||||
}) {
|
||||
// The API computes both in the authority's timezone; the local fallbacks are
|
||||
// only for a cached response from before those fields existed.
|
||||
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
const renewable = license.renewable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(license.licenseType?.name) || 'Licence'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? 'Expired' : license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{expired ? 'Expired on' : 'Valid until'}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{showDate(license.expiryDate)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label="Download certificate">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="lg"
|
||||
loading={isDownloading}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Renewal opens inside the licence type's window and stays open after
|
||||
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
||||
{renewable && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant={expired ? 'filled' : 'light'}
|
||||
color={expired ? 'orange' : undefined}
|
||||
loading={isRenewing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={onRenew}
|
||||
>
|
||||
{expired
|
||||
? 'Renew — this licence has expired'
|
||||
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseCard;
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetMyOperatorTypesQuery,
|
||||
@@ -55,6 +55,7 @@ function formatFee(amount: string | number | null, currency: string): string {
|
||||
|
||||
export function LicenseCatalogue() {
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
const { data: types } = useGetLicenseTypesQuery();
|
||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||
@@ -253,6 +254,7 @@ function LicenseTypeCard({
|
||||
canApply: boolean;
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
useGetAttachmentsQuery,
|
||||
type StaffEvidenceRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface Props {
|
||||
staffId: string;
|
||||
evidence: StaffEvidenceRequirement[];
|
||||
@@ -27,6 +30,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
ownerId: staffId,
|
||||
});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const localized = useLocalized();
|
||||
|
||||
if (!evidence?.length) return null;
|
||||
|
||||
@@ -42,6 +46,13 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: `File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(item.docKey);
|
||||
await uploadDocument({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useGetApplicationQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Resolves `/applications/:applicationId` deep links (notification
|
||||
* callbackUrl, emails) to the canonical `/licensing/:typeCode/applications/:id`
|
||||
* route, which needs the licence type key to load its config.
|
||||
*/
|
||||
export function ApplicationRedirectPage() {
|
||||
const { applicationId } = useParams();
|
||||
const { data, isLoading, isError } = useGetApplicationQuery(applicationId!, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
|
||||
if (!applicationId || isError) {
|
||||
return <Navigate replace to="/licensing/applications" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const typeCode = data?.application.licenseType?.key ?? 'FREIGHT_FORWARDER';
|
||||
return <Navigate replace to={`/licensing/${typeCode}/applications/${applicationId}`} />;
|
||||
}
|
||||
|
||||
export default ApplicationRedirectPage;
|
||||
@@ -29,27 +29,33 @@ import {
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
localized,
|
||||
useLocalized,
|
||||
validateSections,
|
||||
useAddStaffMutation,
|
||||
useCreateApplicationMutation,
|
||||
useGetApplicationQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useGetMyVesselsQuery,
|
||||
usePatchSectionMutation,
|
||||
useRemoveStaffMutation,
|
||||
useResolveRemarkMutation,
|
||||
useResubmitApplicationMutation,
|
||||
useSubmitApplicationMutation,
|
||||
type FieldErrors,
|
||||
type FormFieldConfig,
|
||||
type ValidationIssue,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
|
||||
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
|
||||
import { DocumentSlots } from '../components/DocumentSlots';
|
||||
import { StaffEvidence } from '../components/StaffEvidence';
|
||||
|
||||
@@ -61,9 +67,15 @@ import { StaffEvidence } from '../components/StaffEvidence';
|
||||
export function LicenseApplicationPage() {
|
||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
const { profile } = useCurrentProfile();
|
||||
// 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();
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
@@ -111,6 +123,55 @@ export function LicenseApplicationPage() {
|
||||
if (detail?.application?.formData) setDraft(detail.application.formData);
|
||||
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
||||
|
||||
// Nationality and National ID (Fayda) number are already on file from the
|
||||
// profile's Address tab — carry them into whichever section the form
|
||||
// config puts those fields in, rather than asking again. Only fills a
|
||||
// blank; a value already on the draft (the applicant's own edit, or one
|
||||
// the server saved) is left alone.
|
||||
useEffect(() => {
|
||||
const address = profile?.address;
|
||||
if (!address || !config) return;
|
||||
|
||||
setDraft((prev) => {
|
||||
let next = prev;
|
||||
const fill = (
|
||||
matchField: (label: string, key: string) => boolean,
|
||||
toFieldValue: (field: FormFieldConfig) => unknown,
|
||||
) => {
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
// English-pinned: matched against English substrings below ('nationality', 'fayda').
|
||||
const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key));
|
||||
if (!field) continue;
|
||||
if (next[section.key]?.[field.key]) return; // already set — leave it
|
||||
next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } };
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (address.nationality) {
|
||||
// Profile stores the full country name; the field always renders as
|
||||
// a CountrySelect (see ConfigDrivenSection), which takes alpha-2
|
||||
// codes regardless of the backend's configured field type.
|
||||
fill(
|
||||
(label, key) => key === 'nationality' || label.includes('nationality'),
|
||||
() => getCountryCode(address.nationality) ?? address.nationality,
|
||||
);
|
||||
}
|
||||
if (address.idType === 'NID' && address.idNumber) {
|
||||
fill(
|
||||
(label, key) =>
|
||||
key === 'idNumber' || key === 'nationalId' || key === 'faydaNumber' ||
|
||||
label.includes('fayda') || label.includes('national id'),
|
||||
() => address.idNumber,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Also re-run after the server seed effect (above) replaces `draft`
|
||||
// wholesale — that effect can resolve after this one, wiping the
|
||||
// prefill back out since the server's own draft has none of this yet.
|
||||
}, [profile?.address, config, detail?.application?.id, detail?.application?.formData]);
|
||||
|
||||
const application = detail?.application;
|
||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
@@ -136,8 +197,9 @@ export function LicenseApplicationPage() {
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
language: i18n.language,
|
||||
}),
|
||||
[config, draft],
|
||||
[config, draft, i18n.language],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
@@ -154,15 +216,39 @@ export function LicenseApplicationPage() {
|
||||
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
// it reports the pick up here and this fans it out across every section.
|
||||
function handleVesselSelected(vessel: Vessel) {
|
||||
for (const section of config?.licenseType.formSchema.sections ?? []) {
|
||||
fillFromVessel(vessel, section.fields, (key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
const values = { ...(draft[sectionKey] ?? {}) };
|
||||
// The picker works in alpha-2 codes (CountrySelect); the backend, like
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
const nationalityField = config?.licenseType.formSchema.sections
|
||||
.find((s) => s.key === sectionKey)
|
||||
// English-pinned: same reasoning as the fill() matcher above.
|
||||
?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality'));
|
||||
if (nationalityField && values[nationalityField.key]) {
|
||||
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
|
||||
}
|
||||
try {
|
||||
await patchSection({
|
||||
id: appId as string,
|
||||
sectionKey,
|
||||
values: draft[sectionKey] ?? {},
|
||||
values,
|
||||
}).unwrap();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -176,7 +262,7 @@ export function LicenseApplicationPage() {
|
||||
async function handleSubmit() {
|
||||
setIssues([]);
|
||||
if (!readOnly && currentStep?.sections?.length) {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length) {
|
||||
notifications.show({
|
||||
@@ -236,7 +322,7 @@ export function LicenseApplicationPage() {
|
||||
if (!currentStep || !config) return true;
|
||||
|
||||
if (currentStep.kind === 'sections') {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
@@ -394,7 +480,9 @@ export function LicenseApplicationPage() {
|
||||
section={section}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
onVesselSelected={handleVesselSelected}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly || locked}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
@@ -523,9 +611,11 @@ export function LicenseApplicationPage() {
|
||||
</Text>
|
||||
<ConfigDrivenSection
|
||||
section={section}
|
||||
onVesselSelected={handleVesselSelected}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
@@ -620,17 +710,19 @@ export function LicenseApplicationPage() {
|
||||
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
||||
min={0}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Segmented "pill" tab bar — matches ProfilePage's tab styling. */
|
||||
.list {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.tab[data-active],
|
||||
.tab[data-active]:hover {
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-emaPrimary-7);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
}
|
||||
|
||||
/* Clickable stat tile that doubles as a filter toggle. */
|
||||
.tile {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
.tileActive,
|
||||
.tileActive:hover {
|
||||
border-color: var(--mantine-color-emaPrimary-6);
|
||||
background: var(--mantine-color-emaPrimary-light);
|
||||
}
|
||||
@@ -1,25 +1,49 @@
|
||||
import { useMemo, useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Select,
|
||||
SimpleGrid,
|
||||
Skeleton,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload } from '@tabler/icons-react';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconCertificate,
|
||||
IconClipboardList,
|
||||
IconClockHour4,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconPlus,
|
||||
IconSearch,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
|
||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import {
|
||||
APPLICANT_ACTION_STATUSES,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
applicantOrCompanyName,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useBypassPaymentMutation,
|
||||
@@ -27,24 +51,64 @@ import {
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
import classes from './MyApplicationsPage.module.css';
|
||||
|
||||
/** Days before expiry at which a licence is worth flagging. */
|
||||
const EXPIRY_WARNING_DAYS = 60;
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
type Bucket = 'needsYou' | 'inProgress' | 'completed' | null;
|
||||
|
||||
function bucketOf(status: LicenseStatus): Exclude<Bucket, null> {
|
||||
if (APPLICANT_ACTION_STATUSES.includes(status)) return 'needsYou';
|
||||
if (TERMINAL_STATUSES.includes(status)) return 'completed';
|
||||
return 'inProgress';
|
||||
}
|
||||
|
||||
type Tab = 'applications' | 'licences' | 'apply';
|
||||
const TABS: Tab[] = ['applications', 'licences', 'apply'];
|
||||
|
||||
function tabFromHash(hash: string): Tab {
|
||||
const value = hash.replace('#', '');
|
||||
return (TABS as string[]).includes(value) ? (value as Tab) : 'applications';
|
||||
}
|
||||
|
||||
/**
|
||||
* The applicant's landing page: which licences they can apply for, and the
|
||||
* state of anything already filed.
|
||||
*
|
||||
* The licence types come from the backend, so a newly configured type appears
|
||||
* here without a code change — and each one carries its own document
|
||||
* requirements into the wizard.
|
||||
* Three tabs instead of one long scroll — applications, licences and the
|
||||
* catalogue each own their own space, so a returning applicant lands on
|
||||
* exactly what they came back to check instead of scrolling past it.
|
||||
*/
|
||||
export function MyApplicationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetMyApplicationsQuery();
|
||||
const { t, i18n } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||
const { pay, isPaying } = useApplicationPayment();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const { data: licences } = useGetMyLicensesQuery();
|
||||
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
|
||||
|
||||
const [tab, setTab] = useState<Tab>(() =>
|
||||
typeof window !== 'undefined' ? tabFromHash(window.location.hash) : 'applications',
|
||||
);
|
||||
|
||||
function changeTab(next: Tab) {
|
||||
setTab(next);
|
||||
window.history.replaceState(null, '', `#${next}`);
|
||||
}
|
||||
|
||||
async function handleBypass(applicationId: string) {
|
||||
try {
|
||||
@@ -53,7 +117,7 @@ export function MyApplicationsPage() {
|
||||
color: 'teal',
|
||||
title: 'Payment bypassed',
|
||||
message: result.certificateIssued
|
||||
? 'The licence has been issued — see My licences above.'
|
||||
? 'The licence has been issued — see the Licences tab.'
|
||||
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||
});
|
||||
} catch (err) {
|
||||
@@ -73,15 +137,13 @@ export function MyApplicationsPage() {
|
||||
* find it in a separate table.
|
||||
*/
|
||||
async function openCertificateForApplication(applicationId: string) {
|
||||
const licence = (licences?.items ?? []).find(
|
||||
(l) => l.applicationId === applicationId,
|
||||
);
|
||||
const licence = (licences?.items ?? []).find((l) => l.applicationId === applicationId);
|
||||
if (!licence) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: 'Certificate not ready',
|
||||
message:
|
||||
'The licence for this application has not been issued yet. It will appear under My licences.',
|
||||
'The licence for this application has not been issued yet. It will appear under Licences.',
|
||||
});
|
||||
return;
|
||||
}
|
||||
@@ -89,6 +151,7 @@ export function MyApplicationsPage() {
|
||||
}
|
||||
|
||||
async function downloadCertificate(licenseId: string) {
|
||||
setIsDownloadingCert(true);
|
||||
try {
|
||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(url, '_blank', 'noopener');
|
||||
@@ -98,225 +161,446 @@ export function MyApplicationsPage() {
|
||||
title: 'Could not open the certificate',
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
} finally {
|
||||
setIsDownloadingCert(false);
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
const allItems = data?.items ?? [];
|
||||
const licenceItems = licences?.items ?? [];
|
||||
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
|
||||
const expiringSoon = activeLicences.filter((l) => {
|
||||
const days = l.daysUntilExpiry ?? daysUntil(l.expiryDate);
|
||||
return days >= 0 && days <= EXPIRY_WARNING_DAYS;
|
||||
});
|
||||
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<LicenseStatus | 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 { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const result = { needsYou: 0, inProgress: 0, completed: 0 };
|
||||
for (const app of allItems) result[bucketOf(app.status)]++;
|
||||
return result;
|
||||
}, [allItems]);
|
||||
|
||||
const items = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
const filtered = allItems.filter((app) => {
|
||||
if (bucketFilter && bucketOf(app.status) !== bucketFilter) return false;
|
||||
if (q) {
|
||||
const haystack = `${app.applicationNumber} ${applicantOrCompanyName(app) ?? ''}`.toLowerCase();
|
||||
if (!haystack.includes(q)) return false;
|
||||
}
|
||||
if (statusFilter && app.status !== statusFilter) 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;
|
||||
if (dateFrom && (!at || at < dateFrom)) return false;
|
||||
if (dateTo && (!at || at > `${dateTo}T23:59:59.999Z`)) return false;
|
||||
return true;
|
||||
});
|
||||
// Whatever needs the applicant's attention floats to the top; within a
|
||||
// group, the most recently touched application comes first.
|
||||
return [...filtered].sort((a, b) => {
|
||||
const aNeeds = APPLICANT_ACTION_STATUSES.includes(a.status) ? 0 : 1;
|
||||
const bNeeds = APPLICANT_ACTION_STATUSES.includes(b.status) ? 0 : 1;
|
||||
if (aNeeds !== bNeeds) return aNeeds - bNeeds;
|
||||
const aAt = a.submittedAt ?? a.createdAt;
|
||||
const bAt = b.submittedAt ?? b.createdAt;
|
||||
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
|
||||
});
|
||||
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
|
||||
|
||||
const page = paginate(items);
|
||||
|
||||
function clearFilters() {
|
||||
setSearch('');
|
||||
setStatusFilter(null);
|
||||
setDateFrom('');
|
||||
setDateTo('');
|
||||
setBucketFilter(null);
|
||||
setPageIndex(0);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
function toggleBucket(bucket: Exclude<Bucket, null>) {
|
||||
setBucketFilter((current) => (current === bucket ? null : bucket));
|
||||
setPageIndex(0);
|
||||
setTab('applications');
|
||||
}
|
||||
|
||||
const statusLabel = (status: LicenseStatus) =>
|
||||
t(`applications.status.${status}`, { defaultValue: status });
|
||||
|
||||
const allStatuses = Object.keys(STATUS_COLORS) as LicenseStatus[];
|
||||
|
||||
const applicationColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
header: t('applications.table.licence'),
|
||||
cell: ({ row }) => (
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(row.original.licenseType?.name, i18n.language) || '—'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('applications.table.applicant'),
|
||||
cell: ({ row }) => <Text size="sm">{applicantOrCompanyName(row.original) ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
header: t('common.status'),
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]} variant="light">
|
||||
{statusLabel(row.original.status)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('applications.table.progress'),
|
||||
size: 140,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: t('common.date'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{row.original.submittedAt
|
||||
? showDate(row.original.submittedAt)
|
||||
: t('applications.card.notFiled')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: t('common.actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const app = row.original;
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid and issues the licence"
|
||||
>
|
||||
{t('applications.actions.bypass')}
|
||||
</Button>
|
||||
)}
|
||||
{/* An issued application's primary action is the certificate. It
|
||||
used to be "View", which opened the application wizard — so
|
||||
the one thing the applicant came back for was the one thing
|
||||
the button did not do. */}
|
||||
{app.status === 'CERTIFICATE_ISSUED' && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => openCertificateForApplication(app.id)}
|
||||
>
|
||||
{t('applications.actions.certificate')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
||||
variant={
|
||||
app.status === 'RESUBMIT_REQUIRED' || app.status === 'PAYMENT_PENDING' ? 'filled' : 'subtle'
|
||||
}
|
||||
color={
|
||||
app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'orange'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? 'yellow'
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
// Paying leaves the SPA for Telebirr, so this is a provider
|
||||
// hand-off rather than a route change.
|
||||
app.status === 'PAYMENT_PENDING'
|
||||
? pay(app.id)
|
||||
: navigate(`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT'
|
||||
? t('applications.actions.continue')
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? t('applications.actions.fixResubmit')
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? t('applications.actions.pay', {
|
||||
amount: Number(app.feeAmount ?? 0).toLocaleString(),
|
||||
currency: app.feeCurrency,
|
||||
})
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? t('applications.actions.certificate')
|
||||
: t('applications.actions.view')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="lg" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
Licence applications
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Your licences and applications, and the catalogue to file a new one.
|
||||
</Text>
|
||||
|
||||
{(licences?.items ?? []).length > 0 && (
|
||||
<>
|
||||
<Title order={4} mb="sm">
|
||||
My licences
|
||||
</Title>
|
||||
<Card withBorder padding={0} mb="xl">
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate</Table.Th>
|
||||
<Table.Th>Licence</Table.Th>
|
||||
<Table.Th>Valid until</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(licences?.items ?? []).map((licence) => (
|
||||
<Table.Tr key={licence.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500} ff="monospace">
|
||||
{licence.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">
|
||||
{localized(licence.licenseType?.name) || '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{new Date(licence.expiryDate).toLocaleDateString()}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
variant="light"
|
||||
color={licence.status === 'ACTIVE' ? 'green' : 'gray'}
|
||||
>
|
||||
{licence.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => downloadCertificate(licence.id)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Title order={4} mb="sm">
|
||||
My applications
|
||||
</Title>
|
||||
|
||||
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
|
||||
<Card withBorder padding="sm" radius="md" mb="sm"
|
||||
style={{ borderLeft: '3px solid var(--mantine-color-teal-5)' }}>
|
||||
<Text size="sm">
|
||||
Your payment has been received. The certificate is being prepared
|
||||
and will appear under My licences above once it is issued.
|
||||
</Text>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<Text c="dimmed">You have not filed any applications yet.</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
Pick a licence below to get started.
|
||||
<Container size="lg" py="lg">
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||
<Box>
|
||||
<Title order={2}>{t('applications.title')}</Title>
|
||||
<Text c="dimmed" size="sm" mt={2}>
|
||||
{t('applications.subtitle')}
|
||||
</Text>
|
||||
</Stack>
|
||||
</Card>
|
||||
) : (
|
||||
<Card withBorder padding={0}>
|
||||
<Table highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Number</Table.Th>
|
||||
<Table.Th>Company</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>Submitted</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{items.map((app) => (
|
||||
<Table.Tr key={app.id}>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{app.companyName ?? '—'}</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString()
|
||||
: '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td align="right">
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{capabilities?.bypassEnabled && app.status === 'PAYMENT_PENDING' && (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="default"
|
||||
loading={bypassing}
|
||||
onClick={() => handleBypass(app.id)}
|
||||
title="Testing only — marks the fee paid and issues the licence"
|
||||
>
|
||||
Bypass payment
|
||||
</Button>
|
||||
)}
|
||||
{/* An issued application's primary action is the
|
||||
certificate. It used to be "View", which opened the
|
||||
application wizard — so the one thing the applicant
|
||||
came back for was the one thing the button did not do. */}
|
||||
{app.status === 'CERTIFICATE_ISSUED' && (
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => openCertificateForApplication(app.id)}
|
||||
>
|
||||
Certificate
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
loading={isPaying && app.status === 'PAYMENT_PENDING'}
|
||||
variant={
|
||||
app.status === 'RESUBMIT_REQUIRED' ||
|
||||
app.status === 'PAYMENT_PENDING'
|
||||
? 'filled'
|
||||
: 'subtle'
|
||||
}
|
||||
color={
|
||||
app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'orange'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? 'yellow'
|
||||
: undefined
|
||||
}
|
||||
onClick={() =>
|
||||
// Paying leaves the SPA for Telebirr, so this is a
|
||||
// provider hand-off rather than a route change.
|
||||
app.status === 'PAYMENT_PENDING'
|
||||
? pay(app.id)
|
||||
: navigate(
|
||||
`/licensing/${app.licenseType?.key ?? 'FREIGHT_FORWARDER'}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT'
|
||||
? 'Continue'
|
||||
: app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Fix & resubmit'
|
||||
: app.status === 'PAYMENT_PENDING'
|
||||
? `Pay ${Number(app.feeAmount ?? 0).toLocaleString()} ${app.feeCurrency}`
|
||||
: app.status === 'CERTIFICATE_ISSUED'
|
||||
? 'Application'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Card>
|
||||
)}
|
||||
</Box>
|
||||
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
|
||||
{t('applications.newApplication')}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
{/* Last, for the same reason as on the dashboard: someone opening this
|
||||
page came to check on what they already filed, not to browse. */}
|
||||
<Title order={4} mt="xl" mb="sm">
|
||||
Apply for a licence
|
||||
</Title>
|
||||
<LicenseCatalogue />
|
||||
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||
<StatTile
|
||||
label={t('applications.stats.needsYou')}
|
||||
value={counts.needsYou}
|
||||
icon={IconAlertTriangle}
|
||||
color="orange"
|
||||
active={bucketFilter === 'needsYou'}
|
||||
onClick={() => toggleBucket('needsYou')}
|
||||
/>
|
||||
<StatTile
|
||||
label={t('applications.stats.inProgress')}
|
||||
value={counts.inProgress}
|
||||
icon={IconClockHour4}
|
||||
color="blue"
|
||||
active={bucketFilter === 'inProgress'}
|
||||
onClick={() => toggleBucket('inProgress')}
|
||||
/>
|
||||
<StatTile
|
||||
label={t('applications.stats.completed')}
|
||||
value={counts.completed}
|
||||
icon={IconClipboardList}
|
||||
color="teal"
|
||||
active={bucketFilter === 'completed'}
|
||||
onClick={() => toggleBucket('completed')}
|
||||
/>
|
||||
<StatTile
|
||||
label={t('applications.stats.activeLicences')}
|
||||
value={activeLicences.length}
|
||||
icon={IconCertificate}
|
||||
color="grape"
|
||||
active={tab === 'licences'}
|
||||
onClick={() => changeTab('licences')}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Tabs
|
||||
value={tab}
|
||||
onChange={(v) => changeTab((v as Tab) ?? 'applications')}
|
||||
variant="pills"
|
||||
classNames={{ list: classes.list, tab: classes.tab }}
|
||||
>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="applications">{t('applications.tabs.applications')}</Tabs.Tab>
|
||||
<Tabs.Tab value="licences">{t('applications.tabs.licences')}</Tabs.Tab>
|
||||
<Tabs.Tab value="apply">{t('applications.tabs.apply')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
</Tabs>
|
||||
|
||||
{tab === 'applications' && (
|
||||
<Stack gap="md">
|
||||
{items.some((a) => a.status === 'PAYMENT_CONFIRMED' || a.status === 'PAID') && (
|
||||
<Alert variant="light" color="teal" radius="md">
|
||||
{t('applications.notice.paymentReceived')}
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{allItems.length > 0 && (
|
||||
<Paper withBorder p="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('applications.filters.search')}
|
||||
placeholder={t('applications.filters.searchPlaceholder')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => {
|
||||
setSearch(e.currentTarget.value);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
w={220}
|
||||
/>
|
||||
<Select
|
||||
label={t('applications.filters.status')}
|
||||
placeholder={t('applications.filters.any')}
|
||||
data={allStatuses.map((s) => ({ value: s, label: statusLabel(s) }))}
|
||||
value={statusFilter}
|
||||
onChange={(v) => {
|
||||
setStatusFilter(v as LicenseStatus | null);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
clearable
|
||||
w={200}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t('applications.filters.from')}
|
||||
value={dateFrom}
|
||||
onChange={(v) => {
|
||||
setDateFrom(v);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
dateFormat="date"
|
||||
w={160}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t('applications.filters.to')}
|
||||
value={dateTo}
|
||||
onChange={(v) => {
|
||||
setDateTo(v);
|
||||
setPageIndex(0);
|
||||
}}
|
||||
dateFormat="date"
|
||||
w={160}
|
||||
/>
|
||||
{hasFilters && (
|
||||
<Button variant="subtle" leftSection={<IconX size={14} />} onClick={clearFilters}>
|
||||
{t('applications.filters.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
|
||||
{!isFetching && items.length === 0 ? (
|
||||
<EmptyState
|
||||
icon={IconFileText}
|
||||
title={hasFilters ? t('applications.empty.noMatchTitle') : t('applications.empty.noneTitle')}
|
||||
description={hasFilters ? t('applications.empty.noMatchBody') : t('applications.empty.noneBody')}
|
||||
action={
|
||||
hasFilters
|
||||
? { label: t('applications.empty.clearFilters'), onClick: clearFilters }
|
||||
: { label: t('applications.empty.browse'), onClick: () => changeTab('apply') }
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={applicationColumns}
|
||||
data={page.rows}
|
||||
tableName={t('applications.tabs.applications')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === 'licences' && (
|
||||
<Stack gap="md">
|
||||
{expiringSoon.length > 0 && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color="orange"
|
||||
radius="md"
|
||||
icon={<IconClockHour4 size={18} />}
|
||||
title={t('applications.notice.expiringSoon', { count: expiringSoon.length })}
|
||||
>
|
||||
<Text size="sm">
|
||||
{expiringSoon
|
||||
.map((l) => `${l.certificateNumber} — ${l.daysUntilExpiry ?? daysUntil(l.expiryDate)}d`)
|
||||
.join(' · ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
{isFetchingLicences ? (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
<Skeleton height={160} radius="md" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
<Skeleton height={160} radius="md" />
|
||||
</SimpleGrid>
|
||||
) : licenceItems.length === 0 ? (
|
||||
<EmptyState icon={IconCertificate} title={t('applications.licences.empty')} />
|
||||
) : (
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md">
|
||||
{licenceItems.map((license) => (
|
||||
<LicenseCard
|
||||
key={license.id}
|
||||
license={license}
|
||||
isDownloading={isDownloadingCert}
|
||||
isRenewing={isRenewing}
|
||||
onDownload={() => downloadCertificate(license.id)}
|
||||
onRenew={() => renewLicense(license)}
|
||||
/>
|
||||
))}
|
||||
</SimpleGrid>
|
||||
)}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{tab === 'apply' && <LicenseCatalogue />}
|
||||
</Stack>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
// --------------------------------------------------------------- components
|
||||
|
||||
function StatTile({
|
||||
label,
|
||||
value,
|
||||
icon: Icon,
|
||||
color,
|
||||
active,
|
||||
onClick,
|
||||
}: {
|
||||
label: string;
|
||||
value: number;
|
||||
icon: typeof IconAlertTriangle;
|
||||
color: string;
|
||||
active: boolean;
|
||||
onClick: () => void;
|
||||
}) {
|
||||
return (
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
className={`${classes.tile} ${active ? classes.tileActive : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz={30} fw={700} lh={1.2} mt={4}>
|
||||
{value}
|
||||
</Text>
|
||||
</Box>
|
||||
<ThemeIcon variant="light" color={color} radius="md" size="lg">
|
||||
<Icon size={18} />
|
||||
</ThemeIcon>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
export default MyApplicationsPage;
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
|
||||
import { ErrorState } from '@ema-platform/ui';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
import { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location, LocationType } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -9,12 +11,15 @@ interface LocationPickerProps {
|
||||
onChange?: (locationId: string | null) => void;
|
||||
onChainChange?: (chain: Location[]) => void;
|
||||
required?: boolean;
|
||||
/** Caps how many cascading levels render (e.g. 3 = City / Sub-city / Woreda only). */
|
||||
maxDepth?: number;
|
||||
}
|
||||
|
||||
export function LocationPicker({ value, onChange, onChainChange, required }: LocationPickerProps) {
|
||||
export function LocationPicker({ value, onChange, onChainChange, required, maxDepth }: LocationPickerProps) {
|
||||
const { t } = useTranslation();
|
||||
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
|
||||
const localized = useLocalized();
|
||||
const { data: typesRes, isLoading: typesLoading, isError: typesError, refetch: refetchTypes } = useGetLocationTypesQuery();
|
||||
const { data: locsRes, isLoading: locsLoading, isError: locsError, refetch: refetchLocs } = useGetLocationsQuery({ take: 10000 });
|
||||
|
||||
const locationTypes = typesRes?.items ?? [];
|
||||
const allLocations = locsRes?.items ?? [];
|
||||
@@ -71,10 +76,10 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
if (currentLevelChildren.length === 0) return '';
|
||||
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
}, [currentLevelChildren, typeMap]);
|
||||
}, [currentLevelChildren, typeMap, localized]);
|
||||
|
||||
const depth = selectedChain.length;
|
||||
|
||||
@@ -113,7 +118,7 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
if (levelIdx === 0) {
|
||||
const roots = childrenByParentId.get('__root__') ?? [];
|
||||
return roots
|
||||
.map((loc) => ({ value: loc.id, label: loc.names.en }))
|
||||
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
}
|
||||
|
||||
@@ -121,7 +126,7 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
if (!parent) return [];
|
||||
const children = childrenByParentId.get(parent.id) ?? [];
|
||||
return children
|
||||
.map((loc) => ({ value: loc.id, label: loc.names.en }))
|
||||
.map((loc) => ({ value: loc.id, label: localized(loc.names) }))
|
||||
.sort((a, b) => a.label.localeCompare(b.label));
|
||||
};
|
||||
|
||||
@@ -131,8 +136,8 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
if (roots.length === 0) return '';
|
||||
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
}
|
||||
|
||||
@@ -141,16 +146,16 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
const children = childrenByParentId.get(parent.id) ?? [];
|
||||
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
|
||||
const names = typeIds
|
||||
.map((id) => typeMap.get(id)?.names.en)
|
||||
.filter(Boolean) as string[];
|
||||
return names.join(' / ') || t('location.subLocation');
|
||||
.map((id) => localized(typeMap.get(id)?.names))
|
||||
.filter(Boolean);
|
||||
return names.join(' / ');
|
||||
};
|
||||
|
||||
const selectedPath = useMemo(() => {
|
||||
return selectedChain
|
||||
.map((loc) => loc.names.en)
|
||||
.map((loc) => localized(loc.names))
|
||||
.join(' → ');
|
||||
}, [selectedChain]);
|
||||
}, [selectedChain, localized]);
|
||||
|
||||
if (typesLoading || locsLoading) {
|
||||
return (
|
||||
@@ -160,6 +165,18 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
);
|
||||
}
|
||||
|
||||
if (typesError || locsError) {
|
||||
return (
|
||||
<ErrorState
|
||||
title={t('location.loadFailed')}
|
||||
onRetry={() => {
|
||||
refetchTypes();
|
||||
refetchLocs();
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
const roots = childrenByParentId.get('__root__') ?? [];
|
||||
|
||||
if (roots.length === 0) {
|
||||
@@ -170,9 +187,13 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
);
|
||||
}
|
||||
|
||||
const totalRenderedLevels = Math.max(
|
||||
1,
|
||||
selectedChain.length + (currentLevelChildren.length > 0 ? 1 : 0),
|
||||
// Only offer a further level when its location type resolves to a known
|
||||
// name — an unnamed/unmapped type (e.g. a stray Kebele row) would otherwise
|
||||
// render as a dead-end "Sub-location" picker whose selection is silently
|
||||
// dropped (AddressFormContent only maps Region/City/SubCity/Woreda).
|
||||
const totalRenderedLevels = Math.min(
|
||||
maxDepth ?? Infinity,
|
||||
Math.max(1, selectedChain.length + (currentLevelChildren.length > 0 && levelLabel ? 1 : 0)),
|
||||
);
|
||||
|
||||
const levels = Array.from({ length: totalRenderedLevels }, (_, i) => i);
|
||||
@@ -217,8 +238,8 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
||||
color="blue"
|
||||
style={{ textTransform: 'none' }}
|
||||
>
|
||||
{typeInfo ? `${typeInfo.names.en}: ` : ''}
|
||||
{loc.names.en}
|
||||
{typeInfo ? `${localized(typeInfo.names)}: ` : ''}
|
||||
{localized(loc.names)}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
@@ -7,16 +8,27 @@ import {
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
SegmentedControl,
|
||||
Stack,
|
||||
Text,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetNotificationsQuery,
|
||||
useGetUnseenNotificationsQuery,
|
||||
useMarkNotificationReadMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
type Tab = 'all' | 'unseen' | 'seen';
|
||||
|
||||
const EMPTY_COPY: Record<Tab, string> = {
|
||||
all: 'No notifications yet.',
|
||||
unseen: 'Nothing unread.',
|
||||
seen: 'No read notifications.',
|
||||
};
|
||||
|
||||
/**
|
||||
* The applicant's notification inbox.
|
||||
@@ -26,19 +38,17 @@ import {
|
||||
*/
|
||||
export function NotificationsPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data, isLoading } = useGetNotificationsQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [tab, setTab] = useState<Tab>('all');
|
||||
const all = useGetNotificationsQuery(undefined, { skip: tab === 'unseen' });
|
||||
const unseen = useGetUnseenNotificationsQuery(undefined, { skip: tab !== 'unseen' });
|
||||
const [markRead] = useMarkNotificationReadMutation();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const unread = items.filter((n) => !n.isSeen).length;
|
||||
const { data, isLoading } = tab === 'unseen' ? unseen : all;
|
||||
const items =
|
||||
tab === 'seen' ? (data?.items ?? []).filter((n) => n.isSeen) : (data?.items ?? []);
|
||||
const unread = all.data?.items.filter((n) => !n.isSeen).length ?? 0;
|
||||
|
||||
async function open(id: string, seen: boolean, link?: string) {
|
||||
if (!seen) await markRead(id);
|
||||
@@ -52,11 +62,27 @@ export function NotificationsPage() {
|
||||
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
|
||||
</Text>
|
||||
|
||||
{items.length === 0 ? (
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
mb="md"
|
||||
value={tab}
|
||||
onChange={(v) => setTab(v as Tab)}
|
||||
data={[
|
||||
{ label: 'All', value: 'all' },
|
||||
{ label: 'Unseen', value: 'unseen' },
|
||||
{ label: 'Seen', value: 'seen' },
|
||||
]}
|
||||
/>
|
||||
|
||||
{isLoading ? (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
) : items.length === 0 ? (
|
||||
<Card withBorder padding="xl">
|
||||
<Stack align="center" gap="xs">
|
||||
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
|
||||
<Text c="dimmed">No notifications yet.</Text>
|
||||
<Text c="dimmed">{EMPTY_COPY[tab]}</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You will be notified as your applications progress.
|
||||
</Text>
|
||||
@@ -94,7 +120,7 @@ export function NotificationsPage() {
|
||||
{localized(n.content)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={4}>
|
||||
{new Date(n.createdAt).toLocaleString()}
|
||||
{showDate(n.createdAt)}
|
||||
</Text>
|
||||
</div>
|
||||
{!n.isSeen && (
|
||||
|
||||
@@ -49,8 +49,11 @@ const ALWAYS_ALLOWED = [
|
||||
const MODE_FREE_TYPE_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
'PRE_WAIVER',
|
||||
'POST_WAIVER',
|
||||
];
|
||||
|
||||
@@ -12,11 +12,13 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconCircleCheck } from '@tabler/icons-react';
|
||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** Confirmation that the licence fee has been received. */
|
||||
export function PaymentSuccessPage() {
|
||||
const [params] = useSearchParams();
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const applicationId = params.get('applicationId') ?? '';
|
||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||
skip: !applicationId,
|
||||
@@ -58,7 +60,7 @@ export function PaymentSuccessPage() {
|
||||
{data.paidAt && (
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" c="dimmed">Paid</Text>
|
||||
<Text size="sm">{new Date(data.paidAt).toLocaleString()}</Text>
|
||||
<Text size="sm">{showDate(data.paidAt)}</Text>
|
||||
</Group>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
21
apps/portal/src/app/features/profile/api/address-api.ts
Normal file
21
apps/portal/src/app/features/profile/api/address-api.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import type { AddressPayload } from '../types/address';
|
||||
|
||||
const addressApi = baseApi
|
||||
.enhanceEndpoints({ addTagTypes: ['CurrentProfile'] as const })
|
||||
.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
/** The profile-address endpoint creates or updates through POST. */
|
||||
saveMyAddress: builder.mutation<unknown, { profileId: string; body: AddressPayload }>({
|
||||
query: ({ profileId, body }) => ({
|
||||
url: `/addresss/profile/${profileId}`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_result, error) => (error ? [] : ['CurrentProfile']),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useSaveMyAddressMutation } = addressApi;
|
||||
@@ -2,41 +2,67 @@ import { useCallback, useMemo } from 'react';
|
||||
import { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
||||
import type { Location } from '../../location/types/location';
|
||||
import type { Location, LocationType } from '../../location/types/location';
|
||||
|
||||
export const addressSchema = z.object({
|
||||
idType: z.string().min(1, 'Select ID type'),
|
||||
idNumber: z.string().min(1, 'Enter ID number'),
|
||||
nationality: z.string().min(1, 'Enter nationality'),
|
||||
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
|
||||
secondaryPhoneNumber: z.string().optional(),
|
||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
||||
idNumber: z.string().trim().min(1, 'Enter ID number'),
|
||||
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||
nationality: z.string().min(1, 'Select nationality'),
|
||||
primaryPhoneNumber: ethiopianPhone,
|
||||
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
|
||||
regionId: z.string().optional(),
|
||||
cityId: z.string().optional(),
|
||||
subcityId: z.string().optional(),
|
||||
subCityId: z.string().optional(),
|
||||
woredaId: z.string().optional(),
|
||||
kebeleId: z.string().optional(),
|
||||
streetAddress: z.string().optional(),
|
||||
postalAddress: z.string().optional(),
|
||||
streetAddress: z.string().trim().optional(),
|
||||
postalAddress: z.string().trim().optional(),
|
||||
// Emergency contact is collected but never required — leaving it blank must
|
||||
// not stop an applicant moving on.
|
||||
emergencyContactName: z.string().optional(),
|
||||
emergencyContactPhone: z.string().optional(),
|
||||
emergencyContactRelation: z.string().optional(),
|
||||
emergencyContactName: z.string().trim().optional(),
|
||||
emergencyContactPhone: optionalEthiopianPhone,
|
||||
emergencyContactRelation: z.string().trim().optional(),
|
||||
});
|
||||
|
||||
export type AddressValues = z.infer<typeof addressSchema>;
|
||||
|
||||
export const ID_TYPES = ['NID', 'VITAL', 'PASSPORT', 'DRIVERS_LICENSE'] as const;
|
||||
// Backend rejects anything outside this set:
|
||||
// "idType must be one of the following values: NID, VITAL, PASSPORT, DRIVERS_LICENSE"
|
||||
export const ID_TYPES = [
|
||||
{ value: 'NID', label: 'National Id' },
|
||||
{ value: 'VITAL', label: 'Vital ID' },
|
||||
{ value: 'PASSPORT', label: 'Passport' },
|
||||
{ value: 'DRIVERS_LICENSE', label: "Driver's License" },
|
||||
] as const;
|
||||
|
||||
const LEVEL_TO_FIELD: Record<number, keyof AddressValues> = {
|
||||
1: 'cityId',
|
||||
2: 'subcityId',
|
||||
3: 'woredaId',
|
||||
4: 'kebeleId',
|
||||
};
|
||||
/**
|
||||
* Location types are data-driven rows (no fixed depth), so the chain is
|
||||
* mapped to a field by type *code* rather than by numeric level — this
|
||||
* resolves correctly whether or not a COUNTRY type sits above REGION.
|
||||
*/
|
||||
function fieldsForLocationType(type: LocationType): Array<keyof AddressValues> {
|
||||
// Location types have historically used variants such as SUBCITY,
|
||||
// SUB_CITY, and SUB-CITY. Normalize both the code and display name so a
|
||||
// selected sub-city is never dropped from the address payload.
|
||||
const typeName = `${type.code} ${type.names.en}`
|
||||
.toUpperCase()
|
||||
.replace(/[^A-Z0-9]/g, '');
|
||||
|
||||
if (typeName.includes('SUBCITY')) return ['subCityId'];
|
||||
if (typeName.includes('WOREDA')) return ['woredaId'];
|
||||
if (typeName.includes('REGION')) return ['regionId'];
|
||||
if (typeName.includes('CITY')) {
|
||||
// The profile-completeness API treats the selected City as its region.
|
||||
// Keep its conventional cityId too, as other profile consumers use it.
|
||||
return ['regionId', 'cityId'];
|
||||
}
|
||||
return [];
|
||||
}
|
||||
|
||||
interface AddressFormContentProps {
|
||||
register: UseFormRegister<AddressValues>;
|
||||
@@ -54,35 +80,33 @@ export function AddressFormContent({
|
||||
trigger,
|
||||
}: AddressFormContentProps) {
|
||||
const { data: typesRes } = useGetLocationTypesQuery();
|
||||
const locationTypes = typesRes?.items ?? [];
|
||||
const locationTypes = typesRes?.items;
|
||||
|
||||
const typeLevelMap = useMemo(() => {
|
||||
const map = new Map<string, number>();
|
||||
locationTypes.forEach((lt) => map.set(lt.id, lt.level));
|
||||
const typeFieldMap = useMemo(() => {
|
||||
const map = new Map<string, Array<keyof AddressValues>>();
|
||||
locationTypes?.forEach((lt) => {
|
||||
const fields = fieldsForLocationType(lt);
|
||||
if (fields.length) map.set(lt.id, fields);
|
||||
});
|
||||
return map;
|
||||
}, [locationTypes]);
|
||||
|
||||
const leafId = watch('kebeleId') || watch('woredaId') || watch('subcityId') || watch('cityId') || undefined;
|
||||
const leafId = watch('woredaId') || watch('subCityId') || watch('cityId') || watch('regionId') || undefined;
|
||||
|
||||
const handleChainChange = useCallback(
|
||||
(chain: Location[]) => {
|
||||
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
|
||||
return;
|
||||
}
|
||||
|
||||
setValue('regionId', '');
|
||||
setValue('cityId', '');
|
||||
setValue('subcityId', '');
|
||||
setValue('subCityId', '');
|
||||
setValue('woredaId', '');
|
||||
setValue('kebeleId', '');
|
||||
|
||||
chain.forEach((loc) => {
|
||||
const level = typeLevelMap.get(loc.locationTypeId);
|
||||
if (level && LEVEL_TO_FIELD[level]) {
|
||||
setValue(LEVEL_TO_FIELD[level], loc.id);
|
||||
}
|
||||
typeFieldMap.get(loc.locationTypeId)?.forEach((field) => {
|
||||
setValue(field, loc.id);
|
||||
});
|
||||
});
|
||||
},
|
||||
[setValue, typeLevelMap],
|
||||
[setValue, typeFieldMap],
|
||||
);
|
||||
|
||||
return (
|
||||
@@ -92,7 +116,7 @@ export function AddressFormContent({
|
||||
label="ID Type"
|
||||
placeholder="Select"
|
||||
required
|
||||
data={[...ID_TYPES]}
|
||||
data={ID_TYPES}
|
||||
error={errors.idType?.message}
|
||||
value={watch('idType')}
|
||||
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
||||
@@ -106,17 +130,19 @@ export function AddressFormContent({
|
||||
{...register('idNumber')}
|
||||
error={errors.idNumber?.message}
|
||||
/>
|
||||
<TextInput
|
||||
<CountrySelect
|
||||
label="Nationality"
|
||||
placeholder="e.g. Ethiopian"
|
||||
demonym
|
||||
required
|
||||
{...register('nationality')}
|
||||
value={watch('nationality') || null}
|
||||
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||
error={errors.nationality?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Primary Phone"
|
||||
placeholder="+251 9XX XXX XXX"
|
||||
description="From your account, edit it in the Personal tab"
|
||||
required
|
||||
readOnly
|
||||
{...register('primaryPhoneNumber')}
|
||||
error={errors.primaryPhoneNumber?.message}
|
||||
/>
|
||||
@@ -129,7 +155,8 @@ export function AddressFormContent({
|
||||
<TextInput
|
||||
label="Email"
|
||||
type="email"
|
||||
placeholder="email@example.com"
|
||||
description="From your account, edit it in the Personal tab"
|
||||
readOnly
|
||||
{...register('email')}
|
||||
error={errors.email?.message}
|
||||
/>
|
||||
@@ -138,10 +165,8 @@ export function AddressFormContent({
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
Address
|
||||
</Text>
|
||||
<LocationPicker
|
||||
value={leafId}
|
||||
onChainChange={handleChainChange}
|
||||
/>
|
||||
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
|
||||
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||
<TextInput
|
||||
label="Street Address"
|
||||
@@ -149,6 +174,12 @@ export function AddressFormContent({
|
||||
{...register('streetAddress')}
|
||||
error={errors.streetAddress?.message}
|
||||
/>
|
||||
<TextInput
|
||||
label="Postal Address"
|
||||
placeholder="P.O. Box"
|
||||
{...register('postalAddress')}
|
||||
error={errors.postalAddress?.message}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||
|
||||
@@ -14,12 +14,13 @@ import {
|
||||
import { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetMyOperatorTypesQuery,
|
||||
useUpdateMyOperatorTypesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* The applicant's modes of operation — what they do, and therefore which
|
||||
@@ -40,6 +41,7 @@ export function OperationsFormContent({
|
||||
const { data: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
|
||||
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
||||
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const declaredIds = useMemo(
|
||||
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
|
||||
@@ -62,6 +64,7 @@ export function OperationsFormContent({
|
||||
[catalogue],
|
||||
);
|
||||
|
||||
const showDate = useDateDisplayer();
|
||||
const removed = declaredIds.filter((id) => !selected.includes(id));
|
||||
const dirty =
|
||||
removed.length > 0 || selected.some((id) => !declaredIds.includes(id));
|
||||
@@ -151,13 +154,7 @@ export function OperationsFormContent({
|
||||
|
||||
<Group justify="space-between">
|
||||
<Text size="xs" c="dimmed">
|
||||
{lastChanged
|
||||
? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
})}`
|
||||
: 'Not set yet'}
|
||||
{lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'}
|
||||
</Text>
|
||||
<Group gap="sm">
|
||||
{dirty && (
|
||||
@@ -204,7 +201,7 @@ export function OperationsFormContent({
|
||||
Applications already filed carry on as they are, and licences
|
||||
already issued to you stay valid and can still be renewed.
|
||||
</Text>
|
||||
<Group justify="flex-end" gap="sm">
|
||||
<ModalFooter gap="sm">
|
||||
<Button
|
||||
variant="default"
|
||||
onClick={() => setConfirmingRemoval(false)}
|
||||
@@ -214,7 +211,7 @@ export function OperationsFormContent({
|
||||
<Button color="orange" loading={saving} onClick={persist}>
|
||||
Remove and save
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||
|
||||
export const profileSchema = z.object({
|
||||
professionId: z.string().min(1, 'Select your profession'),
|
||||
@@ -86,11 +87,13 @@ export function ProfileFormContent({
|
||||
onBlur={() => trigger('gender')}
|
||||
name="gender"
|
||||
/>
|
||||
<TextInput
|
||||
<AmharicDatePicker
|
||||
label="Date of Birth"
|
||||
type="date"
|
||||
required
|
||||
{...register('dob')}
|
||||
value={watch('dob')}
|
||||
onChange={(val) => setValue('dob', val, { shouldValidate: true })}
|
||||
onBlur={() => trigger('dob')}
|
||||
name="dob"
|
||||
error={errors.dob?.message}
|
||||
/>
|
||||
<TextInput
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
import { useEffect } from 'react';
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Navigate, useLocation, useParams } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import {
|
||||
PROFILE_FIELD_SECTION,
|
||||
useCurrentProfile,
|
||||
type ProfileRequirement,
|
||||
} from '@ema-platform/auth';
|
||||
|
||||
/**
|
||||
* Seafarer registration is filled in from the profile (nationality, ID,
|
||||
* names, contact details) — the server refuses an application missing them,
|
||||
* so they're asked for up front instead of at submit time.
|
||||
*
|
||||
* Only the fields the Personal, Maritime Profile and Address tabs actually
|
||||
* mark required — matches `profileSchema` / `addressSchema`, so the gate is
|
||||
* always satisfiable by finishing those tabs and never blocks on an optional
|
||||
* field (place of birth, region/city/woreda, emergency contact) the forms
|
||||
* don't star.
|
||||
*/
|
||||
export const SEAFARER_PROFILE_REQUIREMENT: ProfileRequirement = {
|
||||
fields: [
|
||||
'firstName',
|
||||
'middleName',
|
||||
'lastName',
|
||||
'gender',
|
||||
'dob',
|
||||
'maritalStatus',
|
||||
'professionId',
|
||||
'idType',
|
||||
'idNumber',
|
||||
'nationality',
|
||||
'primaryPhoneNumber',
|
||||
'email',
|
||||
],
|
||||
reason:
|
||||
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||
};
|
||||
|
||||
const REGISTRATION_TYPE_KEY = 'SEAFARER_REGISTRATION';
|
||||
|
||||
/**
|
||||
* Sends an applicant with an incomplete profile to `/profile` before they can
|
||||
* reach seafarer registration. Wraps `/seafarer-registration` directly and
|
||||
* `/licensing/:typeCode/apply` when `typeCode` is the seafarer type — the
|
||||
* latter is the shared wizard route every licence type renders through, so
|
||||
* without it the gate is a decoration a deep link skips.
|
||||
*
|
||||
* Fires before the wizard starts, not mid-application, so nothing is lost —
|
||||
* unlike the case `ProfileRequirementGate`'s doc comment warns against
|
||||
* (mid-flow redirects on the old, deleted setup wizard).
|
||||
*/
|
||||
export function RequireSeafarerProfile({ children }: { children: React.ReactNode }) {
|
||||
const { t } = useTranslation();
|
||||
const { typeCode } = useParams();
|
||||
const { pathname } = useLocation();
|
||||
const { isLoading, isFetching, error, gapsFor } = useCurrentProfile();
|
||||
|
||||
// Shared wizard route — only the seafarer type is gated here.
|
||||
const gated = !typeCode || typeCode === REGISTRATION_TYPE_KEY;
|
||||
const gaps = gated ? gapsFor(SEAFARER_PROFILE_REQUIREMENT) : [];
|
||||
const redirecting = gated && !isLoading && !error && !isFetching && gaps.length > 0;
|
||||
|
||||
useEffect(() => {
|
||||
if (!redirecting) return;
|
||||
const fields = gaps.map((field) => t(`profileFields.${field}`, field)).join(', ');
|
||||
notify.info(t('profileGate.seafarerRedirect', { fields }));
|
||||
// Fire once per redirect, not on every render while gaps/gapsFor are
|
||||
// recreated — the toast content is captured at the moment it fires.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [redirecting, pathname]);
|
||||
|
||||
if (!gated) return <>{children}</>;
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
// A failed lookup must not lock anyone out — the server still refuses the
|
||||
// application for a profile it can't fill in from.
|
||||
if (error) return <>{children}</>;
|
||||
|
||||
if (gaps.length === 0) return <>{children}</>;
|
||||
|
||||
// Gaps while a save is still landing are not an answer yet. Saving a
|
||||
// profile tab invalidates this query and the applicant may already be
|
||||
// headed back here in the same tick — deciding on the pre-save cache would
|
||||
// bounce them off the screen they just finished.
|
||||
if (isFetching) {
|
||||
return (
|
||||
<Center h={200}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const target = PROFILE_FIELD_SECTION[gaps[0]];
|
||||
return <Navigate to={`/profile#${target}`} replace />;
|
||||
}
|
||||
@@ -3,7 +3,7 @@
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
background: var(--mantine-color-gray-1);
|
||||
background: var(--mantine-color-gray-light);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: none;
|
||||
flex-wrap: wrap;
|
||||
@@ -14,7 +14,7 @@
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-gray-7);
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
@@ -24,7 +24,7 @@
|
||||
|
||||
.tab:hover {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-gray-9);
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.tab[data-active],
|
||||
@@ -37,7 +37,7 @@
|
||||
|
||||
/* Selectable option card (language + appearance). */
|
||||
.choice {
|
||||
border: 1px solid var(--mantine-color-gray-3);
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
background: var(--mantine-color-body);
|
||||
transition:
|
||||
@@ -46,11 +46,11 @@
|
||||
}
|
||||
|
||||
.choice:hover {
|
||||
border-color: var(--mantine-color-gray-4);
|
||||
border-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
.choiceActive,
|
||||
.choiceActive:hover {
|
||||
border-color: var(--mantine-color-emaPrimary-6);
|
||||
background: var(--mantine-color-emaPrimary-0);
|
||||
background: var(--mantine-color-emaPrimary-light);
|
||||
}
|
||||
|
||||
@@ -47,10 +47,9 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
||||
import type { CurrentProfile } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -64,6 +63,8 @@ import {
|
||||
addressSchema,
|
||||
type AddressValues,
|
||||
} from '../components/AddressFormContent';
|
||||
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||
import { toAddressPayload } from '../types/address';
|
||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||
import classes from './ProfilePage.module.css';
|
||||
|
||||
@@ -85,6 +86,19 @@ function getInitials(name: string, fallback: string) {
|
||||
return letters.toUpperCase();
|
||||
}
|
||||
|
||||
function splitProfileName(fullName: string) {
|
||||
const [firstName = '', middleName = '', ...lastName] = fullName.trim().split(/\s+/);
|
||||
return { firstName, middleName, lastName: lastName.join(' ') };
|
||||
}
|
||||
|
||||
function formatProfileName({ firstName, middleName, lastName }: Pick<ProfileValues, 'firstName' | 'middleName' | 'lastName'>) {
|
||||
return [firstName, middleName, lastName].filter(Boolean).join(' ');
|
||||
}
|
||||
|
||||
function normalizeName(name: string) {
|
||||
return name.trim().replace(/\s+/g, ' ');
|
||||
}
|
||||
|
||||
function passwordScore(pw: string) {
|
||||
if (!pw) return 0;
|
||||
let score = 0;
|
||||
@@ -101,22 +115,22 @@ export function ProfilePage() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [passwordTrigger] = useApiMutation<unknown>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const localized = useLocalized();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string; am?: string } }> }>();
|
||||
|
||||
const [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
||||
const [isSavingAddress, setIsSavingAddress] = useState(false);
|
||||
|
||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||
|
||||
// ---- Profession list (for Profile tab) ----
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
|
||||
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string; am?: string } }>>([]);
|
||||
const [professionsLoading, setProfessionsLoading] = useState(true);
|
||||
const professionsFetched = useRef(false);
|
||||
|
||||
@@ -131,34 +145,28 @@ export function ProfilePage() {
|
||||
}, [fetchProfessions]);
|
||||
|
||||
const professionOptions = useMemo(
|
||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
||||
[professions],
|
||||
() => professions.map((p) => ({ value: p.id, label: localized(p.name) })),
|
||||
[professions, localized],
|
||||
);
|
||||
|
||||
const professionNameMap = useMemo(() => {
|
||||
const map: Record<string, string> = {};
|
||||
professions.forEach((p) => { map[p.id] = p.name.en; });
|
||||
return map;
|
||||
}, [professions]);
|
||||
|
||||
// ---- Profile data ----
|
||||
// Resolved through `useCurrentProfile`, which provisions a profile if the
|
||||
// user has none. The page used to read an id out of local storage that only
|
||||
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
||||
// for anyone who signed up after the wizard was removed.
|
||||
const {
|
||||
profileId,
|
||||
profile: resolvedProfile,
|
||||
isLoading: profileResolving,
|
||||
completeness,
|
||||
missing,
|
||||
refetch: refetchProfile,
|
||||
} = useCurrentProfile();
|
||||
const [updateProfile] = useApiMutation<unknown>();
|
||||
const [updateAddress] = useApiMutation<unknown>();
|
||||
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
||||
|
||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | null>(null);
|
||||
const [profileId, setProfileId] = useState<string | null>(null);
|
||||
const [addressId, setAddressId] = useState<string | null>(null);
|
||||
const [dataLoading, setDataLoading] = useState(true);
|
||||
|
||||
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
||||
@@ -183,68 +191,58 @@ export function ProfilePage() {
|
||||
// already holds so the form does not flash empty on a refetch.
|
||||
const currentProfile = resolvedProfile ?? storedProfile;
|
||||
if (currentProfile) {
|
||||
setProfileId(currentProfile.id);
|
||||
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
|
||||
setLoadedProfile({
|
||||
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
||||
firstName: currentProfile.firstName || '',
|
||||
middleName: currentProfile.middleName || '',
|
||||
lastName: currentProfile.lastName || '',
|
||||
firstName: accountName?.firstName || currentProfile.firstName || '',
|
||||
middleName: accountName?.middleName || currentProfile.middleName || '',
|
||||
lastName: accountName?.lastName || currentProfile.lastName || '',
|
||||
gender: currentProfile.gender || '',
|
||||
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
||||
pob: currentProfile.pob || '',
|
||||
maritalStatus: currentProfile.maritalStatus || '',
|
||||
});
|
||||
|
||||
if (currentProfile.address) {
|
||||
setAddressId(currentProfile.address.id);
|
||||
setLoadedAddress({
|
||||
idType: currentProfile.address.idType || '',
|
||||
idNumber: currentProfile.address.idNumber || '',
|
||||
nationality: currentProfile.address.nationality || '',
|
||||
primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '',
|
||||
secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '',
|
||||
email: currentProfile.address.email || '',
|
||||
regionId: currentProfile.address.regionId || '',
|
||||
cityId: currentProfile.address.cityId || '',
|
||||
subcityId: currentProfile.address.subCityId || '',
|
||||
woredaId: currentProfile.address.woredaId || '',
|
||||
kebeleId: currentProfile.address.kebeleId || '',
|
||||
streetAddress: currentProfile.address.streetAddress || '',
|
||||
postalAddress: currentProfile.address.postalAddress || '',
|
||||
emergencyContactName: currentProfile.address.emergencyContactName || '',
|
||||
emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
|
||||
// Previously read `emergencycontactRelation` (lower-case c), so the
|
||||
// saved relationship never appeared when reopening the profile.
|
||||
emergencyContactRelation:
|
||||
currentProfile.address.emergencyContactRelation || '',
|
||||
});
|
||||
}
|
||||
// Primary phone and email are the account's contact details (same
|
||||
// source as the Personal tab), not the address record — always
|
||||
// populated even before an address exists, and locked in the form.
|
||||
setLoadedAddress({
|
||||
idType: currentProfile.address?.idType || '',
|
||||
idNumber: currentProfile.address?.idNumber || '',
|
||||
// Stored as a country name; the select works in alpha-2 codes.
|
||||
nationality: getCountryCode(currentProfile.address?.nationality) || '',
|
||||
primaryPhoneNumber: user?.phoneNumber || '',
|
||||
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
||||
email: user?.email || '',
|
||||
regionId: currentProfile.address?.regionId || '',
|
||||
cityId: currentProfile.address?.cityId || currentProfile.address?.regionId || '',
|
||||
subCityId: currentProfile.address?.subCityId || '',
|
||||
woredaId: currentProfile.address?.woredaId || '',
|
||||
streetAddress: currentProfile.address?.streetAddress || '',
|
||||
postalAddress: currentProfile.address?.postalAddress || '',
|
||||
emergencyContactName: currentProfile.address?.emergencyContactName || '',
|
||||
emergencyContactPhone: currentProfile.address?.emergencyContactPhone || '',
|
||||
// Previously read `emergencycontactRelation` (lower-case c), so the
|
||||
// saved relationship never appeared when reopening the profile.
|
||||
emergencyContactRelation:
|
||||
currentProfile.address?.emergencyContactRelation || '',
|
||||
});
|
||||
setDataLoading(false);
|
||||
} else if (!profileResolving) {
|
||||
// Resolver finished and there is still nothing — render the empty form
|
||||
// rather than an indefinite spinner.
|
||||
setDataLoading(false);
|
||||
}
|
||||
}, [resolvedProfile, storedProfile, profileResolving]);
|
||||
|
||||
// Load the latest user from the server on mount
|
||||
useEffect(() => {
|
||||
let active = true;
|
||||
meTrigger({ url: '/auth/me', method: 'GET' })
|
||||
.unwrap()
|
||||
.then((me) => {
|
||||
if (active) dispatch(setUser(me));
|
||||
})
|
||||
.catch(() => {
|
||||
// Best-effort refresh; the store already holds the user from sign-in.
|
||||
});
|
||||
return () => { active = false; };
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
}, [resolvedProfile, storedProfile, profileResolving, user]);
|
||||
|
||||
// ---- Personal form (auth user data) ----
|
||||
const personalSchema = z.object({
|
||||
nameEn: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
nameEn: z
|
||||
.string()
|
||||
.refine(
|
||||
(name) => Object.values(splitProfileName(name)).every(Boolean),
|
||||
{ message: 'Enter your first, middle, and last name' },
|
||||
),
|
||||
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||
@@ -269,25 +267,66 @@ export function ProfilePage() {
|
||||
});
|
||||
|
||||
const onSavePersonal = async (values: PersonalValues) => {
|
||||
if (!user) return;
|
||||
|
||||
setIsSavingProfile(true);
|
||||
try {
|
||||
await updateTrigger({
|
||||
url: '/auth/update-profile',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
},
|
||||
}).unwrap();
|
||||
const profileName = splitProfileName(values.nameEn);
|
||||
const saves: Promise<unknown>[] = [
|
||||
updateTrigger({
|
||||
url: '/auth/update-profile',
|
||||
method: 'PATCH',
|
||||
body: {
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
},
|
||||
}).unwrap(),
|
||||
];
|
||||
|
||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
||||
dispatch(setUser(me));
|
||||
// The Profile tab stores names separately as first/middle/last.
|
||||
// Save those fields alongside the account's display name so either tab
|
||||
// always describes the same person.
|
||||
if (profileId) {
|
||||
saves.push(
|
||||
updateProfile({
|
||||
url: `/profiles/${profileId}`,
|
||||
method: 'PATCH',
|
||||
body: profileName,
|
||||
}).unwrap(),
|
||||
);
|
||||
}
|
||||
await Promise.all(saves);
|
||||
|
||||
// Update the session from the values that were just accepted. The
|
||||
// endpoint is allowed to return no body (or a response wrapper), so
|
||||
// treating its response as an AuthUser can blank or retain stale UI
|
||||
// state until the next login.
|
||||
const updatedUser: AuthUser = {
|
||||
...user,
|
||||
email: values.email,
|
||||
username: values.username,
|
||||
phoneNumber: values.phoneNumber,
|
||||
name: { am: values.nameAm, en: values.nameEn },
|
||||
};
|
||||
dispatch(setUser(updatedUser));
|
||||
setLoadedProfile((current) =>
|
||||
current ? { ...current, ...profileName } : current,
|
||||
);
|
||||
resetPersonal({
|
||||
nameEn: updatedUser.name.en,
|
||||
nameAm: updatedUser.name.am,
|
||||
username: updatedUser.username,
|
||||
email: updatedUser.email,
|
||||
phoneNumber: updatedUser.phoneNumber,
|
||||
});
|
||||
// Email/phone feed the profile's completeness check too — without this
|
||||
// `missing` and every requirement gate stay stale until a reload.
|
||||
refetchProfile();
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@@ -308,6 +347,13 @@ export function ProfilePage() {
|
||||
|
||||
const onSaveProfile = async (values: ProfileValues) => {
|
||||
if (!profileId) return;
|
||||
|
||||
const fullName = formatProfileName(values);
|
||||
if (user && normalizeName(fullName) !== normalizeName(user.name.en)) {
|
||||
notify.error('Profile name must match the name in the Personal tab.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSavingMaritime(true);
|
||||
try {
|
||||
await updateProfile({
|
||||
@@ -315,10 +361,15 @@ export function ProfilePage() {
|
||||
method: 'PUT',
|
||||
body: values,
|
||||
}).unwrap();
|
||||
setLoadedProfile({ ...values, pob: values.pob ?? '' });
|
||||
|
||||
// This endpoint doesn't invalidate the `CurrentProfile` tag (unlike
|
||||
// the address save below) — without this, `missing` stays stale until
|
||||
// a reload.
|
||||
refetchProfile();
|
||||
notify.success('Profile updated');
|
||||
} catch {
|
||||
notify.error('Failed to update profile');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingMaritime(false);
|
||||
}
|
||||
@@ -338,23 +389,15 @@ export function ProfilePage() {
|
||||
});
|
||||
|
||||
const onSaveAddress = async (values: AddressValues) => {
|
||||
if (!addressId) return;
|
||||
setIsSavingAddress(true);
|
||||
if (!profileId) return;
|
||||
try {
|
||||
await updateAddress({
|
||||
url: `/addresss/${addressId}`,
|
||||
method: 'PUT',
|
||||
body: {
|
||||
...values,
|
||||
postalAddess: values.postalAddress,
|
||||
},
|
||||
await saveMyAddress({
|
||||
profileId,
|
||||
body: toAddressPayload(values),
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Address updated');
|
||||
} catch {
|
||||
notify.error('Failed to update address');
|
||||
} finally {
|
||||
setIsSavingAddress(false);
|
||||
notify.success('Address saved');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -362,8 +405,8 @@ export function ProfilePage() {
|
||||
const passwordSchema = z
|
||||
.object({
|
||||
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
||||
confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: strongPasswordSchema(8),
|
||||
confirmPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('profile.validation.passwordMismatch'),
|
||||
@@ -397,8 +440,8 @@ export function ProfilePage() {
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
@@ -674,10 +717,6 @@ export function ProfilePage() {
|
||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||
{dataLoading ? (
|
||||
<Center py="xl"><Loader /></Center>
|
||||
) : !loadedAddress ? (
|
||||
<Text c="dimmed" ta="center" py="xl">
|
||||
No address found. Complete your profile setup first.
|
||||
</Text>
|
||||
) : (
|
||||
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
|
||||
<Stack gap="xl">
|
||||
@@ -736,12 +775,15 @@ export function ProfilePage() {
|
||||
{...registerPassword('oldPassword')}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<PasswordRequirements password={watchPassword('newPassword')} minLength={8} />
|
||||
</div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.confirmPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
@@ -770,7 +812,7 @@ export function ProfilePage() {
|
||||
backgroundColor:
|
||||
i <= score
|
||||
? `var(--mantine-color-${strengthColors[score]}-6)`
|
||||
: 'var(--mantine-color-gray-2)',
|
||||
: 'var(--mantine-color-gray-light)',
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
|
||||
58
apps/portal/src/app/features/profile/types/address.ts
Normal file
58
apps/portal/src/app/features/profile/types/address.ts
Normal file
@@ -0,0 +1,58 @@
|
||||
import { getCountryName } from '@ema-platform/ui';
|
||||
import type { AddressValues } from '../components/AddressFormContent';
|
||||
|
||||
/** Exact request body of `POST /addresss/profile/{profileId}`. */
|
||||
export interface AddressPayload {
|
||||
idType: string;
|
||||
idNumber: string;
|
||||
nationality: string;
|
||||
regionId?: string;
|
||||
cityId?: string;
|
||||
subCityId?: string;
|
||||
/** Legacy spelling still accepted by the address upsert endpoint. */
|
||||
subcityId?: string;
|
||||
woredaId?: string;
|
||||
kebeleId?: string;
|
||||
streetAddress?: string;
|
||||
primaryPhoneNumber: string;
|
||||
secondaryPhoneNumber?: string;
|
||||
email?: string;
|
||||
website: null;
|
||||
postalAddress?: string;
|
||||
emergencyContactName?: string;
|
||||
emergencyContactPhone?: string;
|
||||
emergencyContactRelation?: string;
|
||||
}
|
||||
|
||||
/** Blank optional strings drop out; `kebeleId` always mirrors `woredaId`. */
|
||||
export function toAddressPayload(values: AddressValues): AddressPayload {
|
||||
const clean = (v?: string) => (v && v.trim() ? v.trim() : undefined);
|
||||
const regionId = clean(values.regionId);
|
||||
// The location service uses the selected City for the profile's region.
|
||||
// Send that same id as cityId too, because profile completeness requires
|
||||
// both fields even when the location tree has no separate region node.
|
||||
const cityId = clean(values.cityId) ?? regionId;
|
||||
const subCityId = clean(values.subCityId);
|
||||
|
||||
return {
|
||||
idType: values.idType.trim(),
|
||||
idNumber: values.idNumber.trim(),
|
||||
// Form holds the alpha-2 code (CountrySelect); API stores the full name.
|
||||
nationality: getCountryName(values.nationality),
|
||||
regionId,
|
||||
cityId,
|
||||
subCityId,
|
||||
subcityId: subCityId, // legacy spelling, same value
|
||||
woredaId: clean(values.woredaId),
|
||||
kebeleId: clean(values.woredaId), // no separate Kebele field — mirrors woredaId
|
||||
streetAddress: clean(values.streetAddress),
|
||||
primaryPhoneNumber: values.primaryPhoneNumber,
|
||||
secondaryPhoneNumber: clean(values.secondaryPhoneNumber),
|
||||
email: clean(values.email),
|
||||
website: null, // no website field in the form — always sent as null
|
||||
postalAddress: clean(values.postalAddress),
|
||||
emergencyContactName: clean(values.emergencyContactName),
|
||||
emergencyContactPhone: clean(values.emergencyContactPhone),
|
||||
emergencyContactRelation: clean(values.emergencyContactRelation),
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
Anchor,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
FileButton,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -12,7 +13,6 @@ import {
|
||||
Paper,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
@@ -31,7 +31,8 @@ import {
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { useState } from 'react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, AmharicDatePicker, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
uploadDocument,
|
||||
@@ -156,7 +157,8 @@ const EMPTY_SEA_SERVICE = {
|
||||
};
|
||||
|
||||
function SeaServiceTab() {
|
||||
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
|
||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||
const [createRecord, { isLoading: creating }] =
|
||||
useCreateSeaServiceRecordMutation();
|
||||
@@ -237,7 +239,90 @@ function SeaServiceTab() {
|
||||
form.dischargeDate &&
|
||||
form.engagementDate < form.dischargeDate;
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
const page = paginate(records ?? []);
|
||||
|
||||
const columns: AdvancedColumn<SeaServiceRecord>[] = [
|
||||
{
|
||||
header: 'Vessel',
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.vesselName}
|
||||
</Text>
|
||||
{row.original.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {row.original.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ header: 'Rank', accessorKey: 'rank' },
|
||||
{
|
||||
header: 'From',
|
||||
accessorKey: 'engagementDate',
|
||||
cell: ({ row }) => showDate(row.original.engagementDate),
|
||||
},
|
||||
{
|
||||
header: 'To',
|
||||
accessorKey: 'dischargeDate',
|
||||
cell: ({ row }) => showDate(row.original.dischargeDate),
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Tooltip
|
||||
label={row.original.verificationRemark ?? ''}
|
||||
disabled={!row.original.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const record = row.original;
|
||||
const locked = record.status !== 'SUBMITTED';
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(record.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={locked ? 'Verified records are frozen' : 'Edit'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={locked ? 'Verified records are frozen' : 'Delete'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(record)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -264,87 +349,20 @@ function SeaServiceTab() {
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Rank</Table.Th>
|
||||
<Table.Th>From</Table.Th>
|
||||
<Table.Th>To</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(records ?? []).map((record) => {
|
||||
const locked = record.status !== 'SUBMITTED';
|
||||
return (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{record.vesselName}
|
||||
</Text>
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>{record.engagementDate}</Table.Td>
|
||||
<Table.Td>{record.dischargeDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={record.verificationRemark ?? ''}
|
||||
disabled={!record.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(record.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={locked ? 'Verified records are frozen' : 'Edit'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(record)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={locked ? 'Verified records are frozen' : 'Delete'}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(record)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName="Sea service"
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -393,23 +411,23 @@ function SeaServiceTab() {
|
||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||
/>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label="Engagement date"
|
||||
required
|
||||
value={form.engagementDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, engagementDate: e.target.value })
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, engagementDate: val })
|
||||
}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label="Discharge date"
|
||||
required
|
||||
value={form.dischargeDate}
|
||||
onChange={(e) =>
|
||||
setForm({ ...form, dischargeDate: e.target.value })
|
||||
onChange={(val) =>
|
||||
setForm({ ...form, dischargeDate: val })
|
||||
}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
<Textarea
|
||||
@@ -455,7 +473,8 @@ const EMPTY_MEDICAL = {
|
||||
};
|
||||
|
||||
function MedicalTab() {
|
||||
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
|
||||
const [createCertificate, { isLoading: creating }] =
|
||||
useCreateMedicalCertificateMutation();
|
||||
const [updateCertificate, { isLoading: updating }] =
|
||||
@@ -530,7 +549,99 @@ function MedicalTab() {
|
||||
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
|
||||
if (isLoading) return <Loader />;
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
|
||||
const page = paginate(certificates ?? []);
|
||||
|
||||
const columns: AdvancedColumn<MedicalCertificate>[] = [
|
||||
{
|
||||
header: 'Issuer',
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text fw={600} size="sm">
|
||||
{row.original.issuerName}
|
||||
</Text>
|
||||
{row.original.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {row.original.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Issued',
|
||||
accessorKey: 'issueDate',
|
||||
cell: ({ row }) => showDate(row.original.issueDate),
|
||||
},
|
||||
{
|
||||
header: 'Expires',
|
||||
cell: ({ row }) => (
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{showDate(row.original.expiryDate)}
|
||||
{row.original.expiryDate < today && <Badge color="red">Expired</Badge>}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Fitness',
|
||||
cell: ({ row }) =>
|
||||
FITNESS_OPTIONS.find((o) => o.value === row.original.fitnessStatus)
|
||||
?.label ?? row.original.fitnessStatus,
|
||||
},
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Tooltip
|
||||
label={row.original.verificationRemark ?? ''}
|
||||
disabled={!row.original.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[row.original.status]}>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: '',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) => {
|
||||
const certificate = row.original;
|
||||
const locked = certificate.status !== 'SUBMITTED';
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Scan / evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(certificate.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Edit'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(certificate)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={locked ? 'Verified certificates are frozen' : 'Delete'}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(certificate)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
@@ -550,103 +661,20 @@ function MedicalTab() {
|
||||
</Text>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={720}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Issuer</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Fitness</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(certificates ?? []).map((certificate) => {
|
||||
const locked = certificate.status !== 'SUBMITTED';
|
||||
const expired = certificate.expiryDate < today;
|
||||
return (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>
|
||||
<Text fw={600} size="sm">
|
||||
{certificate.issuerName}
|
||||
</Text>
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.issueDate}</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap={6} wrap="nowrap">
|
||||
{certificate.expiryDate}
|
||||
{expired && <Badge color="red">Expired</Badge>}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{FITNESS_OPTIONS.find(
|
||||
(o) => o.value === certificate.fitnessStatus,
|
||||
)?.label ?? certificate.fitnessStatus}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Tooltip
|
||||
label={certificate.verificationRemark ?? ''}
|
||||
disabled={!certificate.verificationRemark}
|
||||
>
|
||||
<Badge color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Tooltip label="Scan / evidence">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
onClick={() => setEvidenceFor(certificate.id)}
|
||||
>
|
||||
<IconPaperclip size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
locked ? 'Verified certificates are frozen' : 'Edit'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
disabled={locked}
|
||||
onClick={() => openEdit(certificate)}
|
||||
>
|
||||
<IconEdit size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
label={
|
||||
locked
|
||||
? 'Verified certificates are frozen'
|
||||
: 'Delete'
|
||||
}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
disabled={locked}
|
||||
onClick={() => remove(certificate)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName="Medical certificates"
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
@@ -673,19 +701,19 @@ function MedicalTab() {
|
||||
/>
|
||||
</Group>
|
||||
<Group grow>
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label="Issue date"
|
||||
required
|
||||
value={form.issueDate}
|
||||
onChange={(e) => setForm({ ...form, issueDate: e.target.value })}
|
||||
onChange={(val) => setForm({ ...form, issueDate: val })}
|
||||
dateFormat="date"
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
<AmharicDatePicker
|
||||
label="Expiry date"
|
||||
required
|
||||
value={form.expiryDate}
|
||||
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
|
||||
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
||||
dateFormat="date"
|
||||
/>
|
||||
</Group>
|
||||
<Select
|
||||
|
||||
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal file
286
apps/portal/src/app/features/vessel-registration/mock.ts
Normal file
@@ -0,0 +1,286 @@
|
||||
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||
|
||||
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
|
||||
|
||||
export const VESSEL_TYPES: Record<VesselCategory, string[]> = {
|
||||
'Inland Waterway': ['Passenger Boat', 'Cargo Barge', 'Ferry', 'Tugboat', 'Fishing Boat'],
|
||||
'Sea-going': ['Bulk Carrier', 'Container Ship', 'Tanker', 'General Cargo', 'Passenger Ship'],
|
||||
};
|
||||
|
||||
export const ENGINE_TYPES = ['Diesel', 'Inboard', 'Outboard', 'Electric', 'Steam'] as const;
|
||||
|
||||
export const HULL_MATERIALS = ['Steel', 'Aluminum', 'Fiberglass', 'Wood', 'Composite'] as const;
|
||||
|
||||
export interface RequiredDocSlot {
|
||||
key: string;
|
||||
label: string;
|
||||
minCount?: number;
|
||||
}
|
||||
|
||||
export const REQUIRED_DOCS: Record<VesselCategory, RequiredDocSlot[]> = {
|
||||
'Inland Waterway': [{ key: 'photos', label: 'Vessel Photos', minCount: 2 }],
|
||||
'Sea-going': [
|
||||
{ key: 'photos', label: 'Vessel Photos' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale' },
|
||||
{ key: 'particulars', label: 'Ship Particulars' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate' },
|
||||
],
|
||||
};
|
||||
|
||||
export const CERTIFICATES: Record<VesselCategory, string[]> = {
|
||||
'Inland Waterway': ['Inland Vessel Registration Certificate'],
|
||||
'Sea-going': [
|
||||
'Certificate of Nationality',
|
||||
'Certificate of Ownership',
|
||||
'Certificate of Registration',
|
||||
'Minimum Safe Manning Certificate',
|
||||
],
|
||||
};
|
||||
|
||||
export type RegistrationStatus = 'Pending' | 'Under Review' | 'Correction Required' | 'Approved' | 'Rejected';
|
||||
|
||||
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'blue',
|
||||
'Correction Required': 'orange',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
|
||||
|
||||
export interface RegistrationOwner {
|
||||
name: string;
|
||||
idOrTin: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface RegistrationCertificate {
|
||||
name: string;
|
||||
number: string;
|
||||
issueDate: string;
|
||||
downloads: number;
|
||||
}
|
||||
|
||||
export interface TimelineStep {
|
||||
date: string | null;
|
||||
event: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface VesselRegistration {
|
||||
id: string;
|
||||
category: VesselCategory;
|
||||
status: RegistrationStatus;
|
||||
submitted: string;
|
||||
remarks?: string;
|
||||
expiryDate?: string;
|
||||
renewal?: RenewalState;
|
||||
timeline: TimelineStep[];
|
||||
certificates?: RegistrationCertificate[];
|
||||
|
||||
// Vessel details
|
||||
vesselName: string;
|
||||
vesselType: string;
|
||||
registrationArea: string;
|
||||
flagState: string;
|
||||
passengerCapacity?: string;
|
||||
grossTonnage?: string;
|
||||
length: string;
|
||||
breadth: string;
|
||||
depth: string;
|
||||
|
||||
// Technical
|
||||
imoNumber?: string;
|
||||
hullNumber?: string;
|
||||
shipyard: string;
|
||||
yearBuilt: string;
|
||||
engineType: string;
|
||||
engineNumber: string;
|
||||
enginePower: string;
|
||||
hullMaterial: string;
|
||||
|
||||
// Ownership
|
||||
owner: RegistrationOwner;
|
||||
}
|
||||
|
||||
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
|
||||
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
|
||||
|
||||
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
|
||||
{
|
||||
id: 'VR-2025-0001',
|
||||
category: 'Sea-going',
|
||||
status: 'Under Review',
|
||||
submitted: '2025-06-20',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-20'),
|
||||
{ date: '2025-06-22', event: 'Document Verification', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
vesselName: 'MV Nile Star',
|
||||
vesselType: 'Bulk Carrier',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '18500',
|
||||
length: '190',
|
||||
breadth: '28',
|
||||
depth: '15',
|
||||
imoNumber: 'IMO9876543',
|
||||
shipyard: 'Hyundai Heavy Industries',
|
||||
yearBuilt: '2016',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-44210',
|
||||
enginePower: '12000 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Nile Shipping PLC',
|
||||
idOrTin: 'TIN-0012345678',
|
||||
phone: '+251911223344',
|
||||
email: 'ops@nileshipping.et',
|
||||
address: 'Bole Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0002',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Correction Required',
|
||||
submitted: '2025-06-10',
|
||||
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-10'),
|
||||
{ date: '2025-06-12', event: 'Document Verification', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
vesselName: 'Tana Ferry 3',
|
||||
vesselType: 'Ferry',
|
||||
registrationArea: 'Lake Tana',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '40',
|
||||
length: '18',
|
||||
breadth: '5',
|
||||
depth: '2',
|
||||
hullNumber: 'HN-2211',
|
||||
shipyard: 'Bahir Dar Boat Works',
|
||||
yearBuilt: '2020',
|
||||
engineType: 'Outboard',
|
||||
engineNumber: 'ENG-9931',
|
||||
enginePower: '150 hp',
|
||||
hullMaterial: 'Fiberglass',
|
||||
owner: {
|
||||
name: 'Getachew Alemu',
|
||||
idOrTin: 'ID-4455667788',
|
||||
phone: '+251922334455',
|
||||
email: 'getachew.alemu@example.com',
|
||||
address: 'Bahir Dar, Amhara',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0003',
|
||||
category: 'Sea-going',
|
||||
status: 'Approved',
|
||||
submitted: '2025-04-05',
|
||||
expiryDate: '2026-08-15',
|
||||
renewal: 'Due Soon',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-04-05'),
|
||||
{ date: '2025-04-08', event: 'Document Verification', done: true },
|
||||
{ date: '2025-04-20', event: 'Inspection', done: true },
|
||||
{ date: '2025-04-28', event: 'Approval', done: true },
|
||||
],
|
||||
certificates: [
|
||||
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28', downloads: 0 },
|
||||
],
|
||||
vesselName: 'MV Abay Voyager',
|
||||
vesselType: 'General Cargo',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '9600',
|
||||
length: '140',
|
||||
breadth: '21',
|
||||
depth: '11',
|
||||
imoNumber: 'IMO9123456',
|
||||
shipyard: 'Damen Shipyards',
|
||||
yearBuilt: '2012',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-33012',
|
||||
enginePower: '7200 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Abay Maritime PLC',
|
||||
idOrTin: 'TIN-0098765432',
|
||||
phone: '+251933445566',
|
||||
email: 'contact@abaymaritime.et',
|
||||
address: 'Kirkos Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0004',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Rejected',
|
||||
submitted: '2025-03-02',
|
||||
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-03-02'),
|
||||
{ date: '2025-03-05', event: 'Document Verification', done: true },
|
||||
{ date: '2025-03-14', event: 'Inspection', done: true },
|
||||
{ date: '2025-03-18', event: 'Approval', done: false },
|
||||
],
|
||||
vesselName: 'Awash Cargo 1',
|
||||
vesselType: 'Cargo Barge',
|
||||
registrationArea: 'Awash River Basin',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '0',
|
||||
length: '22',
|
||||
breadth: '6',
|
||||
depth: '3',
|
||||
hullNumber: 'HN-1187',
|
||||
shipyard: 'Awash River Works',
|
||||
yearBuilt: '2018',
|
||||
engineType: 'Inboard',
|
||||
engineNumber: 'ENG-5567',
|
||||
enginePower: '210 hp',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Selam Tesfaye',
|
||||
idOrTin: 'ID-2233445566',
|
||||
phone: '+251944556677',
|
||||
email: 'selam.tesfaye@example.com',
|
||||
address: 'Adama, Oromia',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
export function addRegistration(
|
||||
reg: Omit<VesselRegistration, 'id' | 'status' | 'submitted' | 'timeline' | 'certificates'>
|
||||
): VesselRegistration {
|
||||
const submitted = new Date().toISOString().slice(0, 10);
|
||||
const created: VesselRegistration = {
|
||||
...reg,
|
||||
id: `VR-2025-${String(MOCK_REGISTRATIONS.length + 1).padStart(4, '0')}`,
|
||||
status: 'Pending',
|
||||
submitted,
|
||||
timeline: [
|
||||
SUBMITTED_STEP(submitted),
|
||||
PENDING_STEP('Document Verification'),
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
};
|
||||
MOCK_REGISTRATIONS.unshift(created);
|
||||
return created;
|
||||
}
|
||||
|
||||
// ponytail: in-memory counter, not persisted.
|
||||
export function recordDownload(regId: string, certName: string): void {
|
||||
const reg = MOCK_REGISTRATIONS.find((r) => r.id === regId);
|
||||
const cert = reg?.certificates?.find((c) => c.name === certName);
|
||||
if (cert) cert.downloads += 1;
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function OwnershipTransferPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default OwnershipTransferPage;
|
||||
@@ -414,8 +414,8 @@ export function VesselRegistrationPage() {
|
||||
<Group gap="xs">
|
||||
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
|
||||
<Text size="sm" c="dimmed">
|
||||
Ownership transfer, amendment and duplicate-certificate services
|
||||
are coming in a later release.
|
||||
Amendment and duplicate-certificate services are coming in a
|
||||
later release.
|
||||
</Text>
|
||||
</Group>
|
||||
</Paper>
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Divider,
|
||||
Group,
|
||||
Paper,
|
||||
SimpleGrid,
|
||||
Stack,
|
||||
Stepper,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
IconArrowLeft,
|
||||
IconCircleCheck,
|
||||
IconClock,
|
||||
IconDownload,
|
||||
IconInfoCircle,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { MOCK_REGISTRATIONS, recordDownload, STATUS_COLOR } from '../mock';
|
||||
|
||||
// ponytail: placeholder PDF blob; wire real cert endpoint when backend lands.
|
||||
const BLANK_PDF = 'data:application/pdf;base64,JVBERi0xLjQKJcfsj6IKMSAwIG9iago8PC9UeXBlL0NhdGFsb2cvUGFnZXMgMiAwIFI+PgplbmRvYmoKMiAwIG9iago8PC9UeXBlL1BhZ2VzL0tpZHNbMyAwIFJdL0NvdW50IDE+PgplbmRvYmoKMyAwIG9iago8PC9UeXBlL1BhZ2UvUGFyZW50IDIgMCBSL01lZGlhQm94WzAgMCA2MTIgNzkyXT4+CmVuZG9iagp4cmVmCjAgNAowMDAwMDAwMDAwIDY1NTM1IGYgCjAwMDAwMDAwMDkgMDAwMDAgbiAKMDAwMDAwMDA1OCAwMDAwMCBuIAowMDAwMDAwMTE1IDAwMDAwIG4gCnRyYWlsZXIKPDwvU2l6ZSA0L1Jvb3QgMSAwIFI+PgpzdGFydHhyZWYKMjE3CiUlRU9G';
|
||||
|
||||
function downloadCertificate(filename: string) {
|
||||
const a = document.createElement('a');
|
||||
a.href = BLANK_PDF;
|
||||
a.download = filename;
|
||||
a.click();
|
||||
}
|
||||
|
||||
export function VesselRegistrationStatusPage() {
|
||||
const navigate = useNavigate();
|
||||
const showDate = useDateDisplayer();
|
||||
const { id } = useParams();
|
||||
const [reg] = useState(() => MOCK_REGISTRATIONS.find((r) => r.id === id) ?? null);
|
||||
const [, forceUpdate] = useState(0);
|
||||
|
||||
if (!reg) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
|
||||
<Alert variant="light" color="red" icon={<IconInfoCircle size={15} />}>Registration not found.</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
const activeStep = reg.timeline.filter((t) => t.done).length - 1;
|
||||
const needsCorrection = reg.status === 'Correction Required' || reg.status === 'Rejected';
|
||||
|
||||
const handleDownload = (certName: string, certNumber: string) => {
|
||||
downloadCertificate(`${certName.replace(/\s+/g, '-')}-${certNumber}.pdf`);
|
||||
recordDownload(reg.id, certName);
|
||||
forceUpdate((n) => n + 1);
|
||||
notify.success(`${certName} downloaded.`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Group gap="sm">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={14} />} onClick={() => navigate('/vessel-registrations')}>Back</Button>
|
||||
<div>
|
||||
<Title order={3}>{reg.vesselName}</Title>
|
||||
<Text fz="sm" c="dimmed">{reg.id} — {reg.category}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Group justify="space-between" mb="md" wrap="wrap" gap="sm">
|
||||
<Group gap="sm">
|
||||
<ThemeIcon size={36} radius="md" color="blue" variant="light"><IconShip size={18} /></ThemeIcon>
|
||||
<div>
|
||||
<Text fw={700} fz="sm">Registration Status</Text>
|
||||
<Text fz="xs" c="dimmed">Submitted {reg.submitted}</Text>
|
||||
</div>
|
||||
</Group>
|
||||
<Badge color={STATUS_COLOR[reg.status]} variant="light" size="lg">{reg.status}</Badge>
|
||||
</Group>
|
||||
|
||||
{reg.renewal && reg.renewal !== 'OK' && (
|
||||
<Alert
|
||||
variant="light"
|
||||
color={reg.renewal === 'Overdue' ? 'red' : 'orange'}
|
||||
icon={<IconAlertTriangle size={15} />}
|
||||
mb="md"
|
||||
p="sm"
|
||||
>
|
||||
<Text fz="sm">
|
||||
Registration {reg.renewal === 'Overdue' ? 'is overdue for renewal' : 'is due for renewal soon'}
|
||||
{reg.expiryDate ? ` — expires ${showDate(reg.expiryDate)}` : ''}.
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
{reg.remarks && (
|
||||
<Alert variant="light" color={needsCorrection ? 'orange' : 'blue'} icon={<IconInfoCircle size={15} />} mb="md" p="sm">
|
||||
<Text fz="sm" fw={600} mb={2}>Officer Remarks</Text>
|
||||
<Text fz="sm">{reg.remarks}</Text>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Stepper active={activeStep} size="sm" color="teal">
|
||||
{reg.timeline.map((step, i) => (
|
||||
<Stepper.Step
|
||||
key={i}
|
||||
label={step.event}
|
||||
description={step.date ?? 'Pending'}
|
||||
icon={step.done ? <IconCircleCheck size={16} /> : <IconClock size={16} />}
|
||||
/>
|
||||
))}
|
||||
</Stepper>
|
||||
|
||||
{needsCorrection && (
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button color="orange" onClick={() => navigate('/vessel-registrations/apply')}>Resubmit Application</Button>
|
||||
</Group>
|
||||
)}
|
||||
</Paper>
|
||||
|
||||
{reg.status === 'Approved' && reg.certificates && (
|
||||
<Paper withBorder radius="lg" p="xl">
|
||||
<Text fw={700} mb="md">Certificates</Text>
|
||||
<Stack gap="sm">
|
||||
{reg.certificates.map((cert) => (
|
||||
<div key={cert.name}>
|
||||
<Group justify="space-between" wrap="wrap" gap="sm">
|
||||
<div>
|
||||
<Text fw={600} fz="sm">{cert.name}</Text>
|
||||
<Text fz="xs" c="dimmed">Certificate No. {cert.number} — Issued {showDate(cert.issueDate)}</Text>
|
||||
{cert.downloads > 0 && (
|
||||
<Text fz="xs" c="dimmed">Downloaded {cert.downloads} time{cert.downloads === 1 ? '' : 's'}</Text>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
size="xs"
|
||||
leftSection={<IconDownload size={14} />}
|
||||
onClick={() => handleDownload(cert.name, cert.number)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Group>
|
||||
<Divider mt="sm" />
|
||||
</div>
|
||||
))}
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
Progress,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconArrowsExchange,
|
||||
IconShip,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
STATUS_PROGRESS,
|
||||
TERMINAL_STATUSES,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyVesselsQuery,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const TRANSFER_TYPE_KEY = 'VESSEL_OWNERSHIP_TRANSFER';
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
INLAND_WATERWAY: 'Inland Waterway',
|
||||
SEA_GOING: 'Sea-going',
|
||||
};
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
REGISTERED: 'green',
|
||||
SUSPENDED: 'orange',
|
||||
DEREGISTERED: 'gray',
|
||||
};
|
||||
|
||||
/**
|
||||
* The vessel owner's ownership-transfer home: transfers in flight, and the
|
||||
* registered vessels eligible to start one. Same config-driven wizard as
|
||||
* registration underneath (VESSEL_OWNERSHIP_TRANSFER) — this page mirrors
|
||||
* VesselRegistrationPage, just a different entry point and no
|
||||
* certificate/renewal/incident actions, which don't apply here.
|
||||
*/
|
||||
export function VesselTransferPage() {
|
||||
const navigate = useNavigate();
|
||||
const { data: vessels, isLoading: loadingVessels } = useGetMyVesselsQuery();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
|
||||
const inFlight = (applications?.items ?? []).filter(
|
||||
(app) =>
|
||||
app.licenseType?.key === TRANSFER_TYPE_KEY &&
|
||||
!TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
|
||||
// A transfer moves ownership of a vessel already on the register — nothing
|
||||
// to transfer without at least one REGISTERED vessel.
|
||||
const hasTransferableVessel = (vessels ?? []).some(
|
||||
(v) => v.status === 'REGISTERED',
|
||||
);
|
||||
|
||||
if (loadingVessels || loadingApplications) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
<Group justify="space-between">
|
||||
<Title order={2}>Ownership Transfer</Title>
|
||||
<Tooltip
|
||||
label="Register a vessel first — there's nothing to transfer yet"
|
||||
disabled={hasTransferableVessel}
|
||||
>
|
||||
<Button
|
||||
leftSection={<IconArrowsExchange size={16} />}
|
||||
disabled={!hasTransferableVessel}
|
||||
onClick={() => navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)}
|
||||
>
|
||||
Start transfer
|
||||
</Button>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* ----------------------------------------------------- in-flight */}
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>Transfers in progress</Title>
|
||||
{inFlight.map((app) => {
|
||||
const isDraft = app.status === 'DRAFT';
|
||||
const needsAction = app.status === 'RESUBMIT_REQUIRED';
|
||||
return (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
mt="xs"
|
||||
w={260}
|
||||
/>
|
||||
</div>
|
||||
<Group wrap="nowrap">
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant={needsAction ? 'filled' : 'light'}
|
||||
color={needsAction ? 'orange' : undefined}
|
||||
rightSection={<IconArrowRight size={14} />}
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${TRANSFER_TYPE_KEY}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{isDraft ? 'Continue' : needsAction ? 'Fix' : 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
{/* ------------------------------------------------------- vessels */}
|
||||
<Stack gap="sm">
|
||||
<Title order={4}>My vessels</Title>
|
||||
{(vessels ?? []).length === 0 ? (
|
||||
<Paper withBorder radius="md" p="xl">
|
||||
<Stack align="center" gap="sm">
|
||||
<IconShip size={40} color="var(--mantine-color-blue-5)" />
|
||||
<Text fw={600}>No registered vessels yet</Text>
|
||||
<Text size="sm" c="dimmed" ta="center" maw={420}>
|
||||
Ownership can only be transferred for a vessel already on the
|
||||
register.
|
||||
</Text>
|
||||
<Button
|
||||
mt="xs"
|
||||
variant="light"
|
||||
onClick={() => navigate('/vessel-registration')}
|
||||
>
|
||||
Go to Vessel Registration
|
||||
</Button>
|
||||
</Stack>
|
||||
</Paper>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped highlightOnHover>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Registration №</Table.Th>
|
||||
<Table.Th>Vessel</Table.Th>
|
||||
<Table.Th>Category</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(vessels ?? []).map((vessel) => {
|
||||
const canTransfer = vessel.status === 'REGISTERED';
|
||||
return (
|
||||
<Table.Tr key={vessel.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
{CATEGORY_LABELS[vessel.category] ?? vessel.category}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={VESSEL_STATUS_COLORS[vessel.status]}
|
||||
>
|
||||
{vessel.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Group justify="flex-end">
|
||||
{canTransfer ? (
|
||||
<Tooltip label="Start an ownership transfer for this vessel">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconArrowsExchange size={14} />}
|
||||
onClick={() =>
|
||||
navigate(`/licensing/${TRANSFER_TYPE_KEY}/apply`)
|
||||
}
|
||||
>
|
||||
Transfer
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Text size="xs" c="dimmed">
|
||||
Not transferable
|
||||
</Text>
|
||||
)}
|
||||
</Group>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselTransferPage;
|
||||
@@ -17,16 +17,19 @@ import {
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||
|
||||
@@ -39,10 +42,13 @@ const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
||||
* verifiable references (US-WAV-010).
|
||||
*/
|
||||
export function WaiverPage() {
|
||||
const { t } = useTranslation();
|
||||
const navigate = useNavigate();
|
||||
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
@@ -59,7 +65,7 @@ export function WaiverPage() {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not fetch the letter'));
|
||||
notify.error(extractErrorMessage(error, t('waiver.fetchFailed', 'Could not fetch the letter')));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -74,65 +80,72 @@ export function WaiverPage() {
|
||||
return (
|
||||
<Stack maw={900} mx="auto">
|
||||
<div>
|
||||
<Title order={2}>Maritime Waiver</Title>
|
||||
<Title order={2}>{t('waiver.title', 'Maritime Waiver')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
Apply for a waiver when Ethiopian Shipping & Logistics cannot
|
||||
carry your shipment. Approval issues the bank waiver letter.
|
||||
{t(
|
||||
'waiver.subtitle',
|
||||
'Apply for a waiver when Ethiopian Shipping & Logistics cannot carry your shipment. Approval issues the bank waiver letter.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={600}>Pre-waiver</Text>
|
||||
<Text fw={600}>{t('waiver.preWaiver.title', 'Pre-waiver')}</Text>
|
||||
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||
The cargo has not yet arrived. Applying before arrival avoids the
|
||||
post-waiver penalty.
|
||||
{t(
|
||||
'waiver.preWaiver.body',
|
||||
'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
||||
>
|
||||
Apply for a pre-waiver
|
||||
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
|
||||
</Button>
|
||||
</Card>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text fw={600}>Post-waiver</Text>
|
||||
<Text fw={600}>{t('waiver.postWaiver.title', 'Post-waiver')}</Text>
|
||||
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||
The cargo has already arrived. Granted once per shipment, and only
|
||||
against a settled penalty with the receipt attached.
|
||||
{t(
|
||||
'waiver.postWaiver.body',
|
||||
'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
|
||||
)}
|
||||
</Text>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
||||
>
|
||||
Apply for a post-waiver
|
||||
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
|
||||
</Button>
|
||||
</Card>
|
||||
</SimpleGrid>
|
||||
|
||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||
Each waiver covers one shipment, identified by its bill of lading. A
|
||||
second application quoting the same bill of lading is refused while the
|
||||
first is live.
|
||||
{t(
|
||||
'waiver.oneShipmentNotice',
|
||||
'Each waiver covers one shipment, identified by its bill of lading. A second application quoting the same bill of lading is refused while the first is live.',
|
||||
)}
|
||||
</Alert>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Applications in progress</Title>
|
||||
<Title order={4}>{t('waiver.inProgress', 'Applications in progress')}</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
@@ -144,8 +157,8 @@ export function WaiverPage() {
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Continue'
|
||||
: 'View'}
|
||||
? t('applications.actions.continue', 'Continue')
|
||||
: t('applications.actions.view', 'View')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
@@ -155,11 +168,11 @@ export function WaiverPage() {
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued waiver letters</Title>
|
||||
<Title order={4}>{t('waiver.issuedLetters', 'Issued waiver letters')}</Title>
|
||||
{letters.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No waiver letters issued yet.
|
||||
{t('waiver.emptyLetters', 'No waiver letters issued yet.')}
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
@@ -167,10 +180,10 @@ export function WaiverPage() {
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Reference</Table.Th>
|
||||
<Table.Th>Kind</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th>{t('waiver.columns.reference', 'Reference')}</Table.Th>
|
||||
<Table.Th>{t('waiver.columns.kind', 'Kind')}</Table.Th>
|
||||
<Table.Th>{t('waiver.columns.issued', 'Issued')}</Table.Th>
|
||||
<Table.Th>{t('waiver.columns.status', 'Status')}</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
@@ -182,15 +195,15 @@ export function WaiverPage() {
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{license.licenseType?.name?.en ?? '—'}</Table.Td>
|
||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
||||
<Table.Td>{localized(license.licenseType?.name) || '—'}</Table.Td>
|
||||
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
{t(`waiver.licenseStatus.${license.status}`, license.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
@@ -200,7 +213,7 @@ export function WaiverPage() {
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Letter
|
||||
{t('waiver.letter', 'Letter')}
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
|
||||
@@ -7,6 +7,17 @@ export const am: Translations = {
|
||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።',
|
||||
authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።',
|
||||
permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።',
|
||||
notFoundError: 'የተጠየቀው ንጥል አልተገኘም።',
|
||||
fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።',
|
||||
networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'ቋንቋ',
|
||||
en: 'English',
|
||||
@@ -42,12 +53,18 @@ export const am: Translations = {
|
||||
myApplication: 'ማመልከቻዬ',
|
||||
certificates: 'የምስክር ወረቀቶች',
|
||||
endorsements: 'ማረጋገጫዎች',
|
||||
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||
documents: 'ሰነዶቼ',
|
||||
notifications: 'ማሳወቂያዎች',
|
||||
profile: 'መገለጫ',
|
||||
support: 'እገዛና ድጋፍ',
|
||||
collapseSidebar: 'ሰብስብ',
|
||||
expandSidebar: 'ዘርጋ',
|
||||
sectionSeafarerServices: 'የመርከበኞች አገልግሎቶች',
|
||||
sectionVesselServices: 'የመርከብ አገልግሎቶች',
|
||||
sectionLogisticsLicensing: 'ሎጂስቲክስና ፈቃድ',
|
||||
sectionAccountManagement: 'የመለያ አስተዳደር',
|
||||
},
|
||||
|
||||
common: {
|
||||
@@ -77,6 +94,8 @@ export const am: Translations = {
|
||||
learnMore: 'ተጨማሪ ይወቁ',
|
||||
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
|
||||
welcome: 'እንኳን ደህና መጡ',
|
||||
switchCalendar: 'የቀን መቁጠሪያ ዓይነት ቀይር',
|
||||
time: 'ሰዓት',
|
||||
},
|
||||
|
||||
auth: {
|
||||
@@ -92,6 +111,85 @@ export const am: Translations = {
|
||||
quickActions: 'ፈጣን ድርጊቶች',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'የፍቃድ ማመልከቻዎች',
|
||||
subtitle: 'ለ EMA ያስገቡትን ሁሉ ይከታተሉ።',
|
||||
newApplication: 'አዲስ ማመልከቻ',
|
||||
tabs: {
|
||||
applications: 'ማመልከቻዎች',
|
||||
licences: 'ፍቃዶች',
|
||||
apply: 'አመልክት',
|
||||
},
|
||||
stats: {
|
||||
needsYou: 'እርምጃ ይፈልጋል',
|
||||
inProgress: 'በሂደት ላይ',
|
||||
completed: 'የተጠናቀቀ',
|
||||
activeLicences: 'ንቁ ፍቃዶች',
|
||||
},
|
||||
filters: {
|
||||
search: 'ፈልግ',
|
||||
searchPlaceholder: 'ቁጥር ወይም አመልካች',
|
||||
status: 'ሁኔታ',
|
||||
any: 'ማንኛውም',
|
||||
from: 'ከ',
|
||||
to: 'እስከ',
|
||||
clear: 'አጽዳ',
|
||||
},
|
||||
empty: {
|
||||
noneTitle: 'እስካሁን ምንም ማመልከቻ አላስገቡም',
|
||||
noneBody: 'ለመጀመር ከ"አመልክት" ትር ፍቃድ ይምረጡ።',
|
||||
noMatchTitle: 'ከዚህ ማጣሪያ ጋር የሚዛመድ ማመልከቻ የለም',
|
||||
noMatchBody: 'ማጣሪያውን ያስፉ ወይም ያጽዱ።',
|
||||
clearFilters: 'ማጣሪያ አጽዳ',
|
||||
browse: 'ፍቃዶችን ይመልከቱ',
|
||||
},
|
||||
card: {
|
||||
submitted: 'የገባው {{date}}',
|
||||
notFiled: 'ገና አልገባም',
|
||||
feeDue: 'የሚከፈል ክፍያ',
|
||||
},
|
||||
table: {
|
||||
licence: 'ፍቃድ',
|
||||
applicant: 'አመልካች',
|
||||
progress: 'ደረጃ',
|
||||
},
|
||||
actions: {
|
||||
continue: 'ቀጥል',
|
||||
fixResubmit: 'አስተካክለህ እንደገና አስገባ',
|
||||
pay: '{{amount}} {{currency}} ክፈል',
|
||||
certificate: 'የምስክር ወረቀት',
|
||||
view: 'ይመልከቱ',
|
||||
bypass: 'ክፍያ ዝለል',
|
||||
renew: 'አድስ',
|
||||
},
|
||||
notice: {
|
||||
paymentReceived:
|
||||
'ክፍያዎ ደርሷል። የምስክር ወረቀቱ በመዘጋጀት ላይ ሲሆን ከተሰጠ በኋላ ከ"ፍቃዶች" ስር ይታያል።',
|
||||
expiringSoon_one: 'አንድ ፍቃድ በቅርቡ ያበቃል',
|
||||
expiringSoon_other: '{{count}} ፍቃዶች በቅርቡ ያበቃሉ',
|
||||
},
|
||||
licences: {
|
||||
empty: 'እስካሁን ምንም ፍቃድ አልተሰጥዎትም። ማመልከቻ ከተፈቀደና ከተከፈለ በኋላ እዚህ ይታያል።',
|
||||
},
|
||||
status: {
|
||||
DRAFT: 'ረቂቅ',
|
||||
SUBMITTED: 'ገብቷል',
|
||||
UNDER_REVIEW: 'በግምገማ ላይ',
|
||||
UNDER_EVALUATION: 'በምዘና ላይ',
|
||||
RESUBMIT_REQUIRED: 'እንደገና ማስገባት ያስፈልጋል',
|
||||
INSPECTION_PENDING: 'ቁጥጥር በመጠባበቅ ላይ',
|
||||
INSPECTION_COMPLETED: 'ቁጥጥር ተጠናቋል',
|
||||
APPROVED: 'ጸድቋል',
|
||||
REJECTED: 'ውድቅ ተደርጓል',
|
||||
ON_HOLD: 'ላይ ቆሟል',
|
||||
PAYMENT_PENDING: 'ክፍያ በመጠባበቅ ላይ',
|
||||
PAID: 'ተከፍሏል',
|
||||
PAYMENT_CONFIRMED: 'ምስክር ወረቀት በዝግጅት ላይ',
|
||||
CERTIFICATE_ISSUED: 'ምስክር ወረቀት ተሰጥቷል',
|
||||
COMPLETED: 'ተጠናቋል',
|
||||
},
|
||||
},
|
||||
|
||||
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||
profileFields: {
|
||||
firstName: 'የመጀመሪያ ስም',
|
||||
@@ -130,6 +228,8 @@ export const am: Translations = {
|
||||
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
||||
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
||||
},
|
||||
|
||||
profileSections: {
|
||||
@@ -216,13 +316,18 @@ export const am: Translations = {
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
select: 'አገር ይምረጡ',
|
||||
notFound: 'ምንም አገር አልተገኘም',
|
||||
},
|
||||
|
||||
location: {
|
||||
select: 'ይምረጡ...',
|
||||
noOptions: 'ምንም አማራጮች አልተገኙም',
|
||||
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
|
||||
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
|
||||
subLocation: 'ንዑስ አካባቢ',
|
||||
loading: 'አካባቢዎች በመጫን ላይ...',
|
||||
loadFailed: 'አካባቢዎችን መጫን አልተቻለም',
|
||||
},
|
||||
|
||||
support: {
|
||||
@@ -247,4 +352,38 @@ export const am: Translations = {
|
||||
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
||||
},
|
||||
},
|
||||
|
||||
waiver: {
|
||||
title: "የባህር ትራንስፖርት ነፃ ፈቃድ",
|
||||
subtitle: "የኢትዮጵያ መርከብ እና ሎጂስቲክስ ጭነትዎን ማጓጓዝ በማይችልበት ጊዜ ነፃ ፈቃድ ያመልክቱ። መጽደቁ የባንክ ነፃ ፈቃድ ደብዳቤ ያወጣል።",
|
||||
preWaiver: {
|
||||
title: "ቅድመ ነፃ ፈቃድ",
|
||||
body: "ጭነቱ ገና አልደረሰም። ከመድረሱ በፊት ማመልከት ድህረ-ነፃ ፈቃድ ቅጣትን ያስቀራል።",
|
||||
apply: "ለቅድመ ነፃ ፈቃድ ያመልክቱ",
|
||||
},
|
||||
postWaiver: {
|
||||
title: "ድህረ ነፃ ፈቃድ",
|
||||
body: "ጭነቱ አስቀድሞ ደርሷል። በአንድ ጭነት አንድ ጊዜ ብቻ ይሰጣል፣ እና ደረሰኙ ተያይዞ የተከፈለ ቅጣት ካለ ብቻ።",
|
||||
apply: "ለድህረ ነፃ ፈቃድ ያመልክቱ",
|
||||
},
|
||||
oneShipmentNotice: "እያንዳንዱ ነፃ ፈቃድ በማጫኛ ሰነዱ የሚለይ አንድ ጭነት ይሸፍናል። ተመሳሳይ የማጫኛ ሰነድ ጠቅሶ የሚቀርብ ሁለተኛ ማመልከቻ የመጀመሪያው ገና ንቁ ሆኖ ሳለ ውድቅ ይደረጋል።",
|
||||
inProgress: "በሂደት ላይ ያሉ ማመልከቻዎች",
|
||||
issuedLetters: "የወጡ ነፃ ፈቃድ ደብዳቤዎች",
|
||||
emptyLetters: "እስካሁን የወጣ ነፃ ፈቃድ ደብዳቤ የለም።",
|
||||
columns: {
|
||||
reference: "ማጣቀሻ",
|
||||
kind: "ዓይነት",
|
||||
issued: "የወጣበት ቀን",
|
||||
status: "ሁኔታ",
|
||||
},
|
||||
letter: "ደብዳቤ",
|
||||
fetchFailed: "ደብዳቤውን ማግኘት አልተቻለም",
|
||||
licenseStatus: {
|
||||
ACTIVE: "ንቁ",
|
||||
EXPIRED: "ጊዜው ያለፈበት",
|
||||
SUSPENDED: "ታግዷል",
|
||||
CANCELLED: "ተሰርዟል",
|
||||
SUPERSEDED: "ተተክቷል",
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
@@ -5,6 +5,17 @@ export const en = {
|
||||
tagline: 'Maritime licensing & certification services',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
validationError: 'Please check your input and try again.',
|
||||
authError: 'Your session has expired. Please sign in again.',
|
||||
permissionError: "You don't have permission to perform this action.",
|
||||
notFoundError: 'The requested item was not found.',
|
||||
fileTooLarge: 'The file is too large to upload.',
|
||||
networkError: 'Network error. Check your connection and try again.',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
@@ -40,12 +51,18 @@ export const en = {
|
||||
waiver: 'Waiver',
|
||||
certificates: 'Certificates',
|
||||
endorsements: 'Endorsements',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Transfers',
|
||||
documents: 'My Documents',
|
||||
notifications: 'Notifications',
|
||||
profile: 'Profile',
|
||||
support: 'Help & Support',
|
||||
collapseSidebar: 'Collapse',
|
||||
expandSidebar: 'Expand sidebar',
|
||||
sectionSeafarerServices: 'Seafarer Services',
|
||||
sectionVesselServices: 'Vessel Services',
|
||||
sectionLogisticsLicensing: 'Logistics and Licensing',
|
||||
sectionAccountManagement: 'Account Management',
|
||||
},
|
||||
|
||||
common: {
|
||||
@@ -75,6 +92,8 @@ export const en = {
|
||||
learnMore: 'Learn more',
|
||||
toggleTheme: 'Toggle light / dark mode',
|
||||
welcome: 'Welcome',
|
||||
switchCalendar: 'Switch calendar type',
|
||||
time: 'Time',
|
||||
},
|
||||
|
||||
auth: {
|
||||
@@ -90,6 +109,85 @@ export const en = {
|
||||
quickActions: 'Quick actions',
|
||||
},
|
||||
|
||||
applications: {
|
||||
title: 'Licence applications',
|
||||
subtitle: 'Track everything you have filed with EMA.',
|
||||
newApplication: 'New application',
|
||||
tabs: {
|
||||
applications: 'Applications',
|
||||
licences: 'Licences',
|
||||
apply: 'Apply',
|
||||
},
|
||||
stats: {
|
||||
needsYou: 'Needs your action',
|
||||
inProgress: 'In progress',
|
||||
completed: 'Completed',
|
||||
activeLicences: 'Active licences',
|
||||
},
|
||||
filters: {
|
||||
search: 'Search',
|
||||
searchPlaceholder: 'Number or applicant',
|
||||
status: 'Status',
|
||||
any: 'Any',
|
||||
from: 'From',
|
||||
to: 'To',
|
||||
clear: 'Clear',
|
||||
},
|
||||
empty: {
|
||||
noneTitle: 'You have not filed any applications yet',
|
||||
noneBody: 'Pick a licence from the Apply tab to get started.',
|
||||
noMatchTitle: 'No applications match these filters',
|
||||
noMatchBody: 'Try widening or clearing the filters.',
|
||||
clearFilters: 'Clear filters',
|
||||
browse: 'Browse licences',
|
||||
},
|
||||
card: {
|
||||
submitted: 'Submitted {{date}}',
|
||||
notFiled: 'Not filed yet',
|
||||
feeDue: 'Fee due',
|
||||
},
|
||||
table: {
|
||||
licence: 'Licence',
|
||||
applicant: 'Applicant',
|
||||
progress: 'Progress',
|
||||
},
|
||||
actions: {
|
||||
continue: 'Continue',
|
||||
fixResubmit: 'Fix & resubmit',
|
||||
pay: 'Pay {{amount}} {{currency}}',
|
||||
certificate: 'Certificate',
|
||||
view: 'View',
|
||||
bypass: 'Bypass payment',
|
||||
renew: 'Renew',
|
||||
},
|
||||
notice: {
|
||||
paymentReceived:
|
||||
'Your payment has been received. The certificate is being prepared and will appear under Licences once it is issued.',
|
||||
expiringSoon_one: 'A licence is expiring soon',
|
||||
expiringSoon_other: '{{count}} licences are expiring soon',
|
||||
},
|
||||
licences: {
|
||||
empty: 'No licence has been issued to you yet. One appears here once an application is approved and paid.',
|
||||
},
|
||||
status: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
},
|
||||
},
|
||||
|
||||
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||
profileFields: {
|
||||
firstName: 'First name',
|
||||
@@ -128,6 +226,9 @@ export const en = {
|
||||
title_other: 'We need {{count}} more details before you continue',
|
||||
addDetails: 'Add these details',
|
||||
viewProfile: 'View full profile',
|
||||
seafarerReason:
|
||||
'Seafarer registration is built from your profile — these details fill it in for you.',
|
||||
seafarerRedirect: 'Finish your profile to register as a seafarer. Still needed: {{fields}}',
|
||||
},
|
||||
|
||||
profileSections: {
|
||||
@@ -214,13 +315,18 @@ export const en = {
|
||||
},
|
||||
},
|
||||
|
||||
country: {
|
||||
select: 'Select a country',
|
||||
notFound: 'No countries found',
|
||||
},
|
||||
|
||||
location: {
|
||||
select: 'Select...',
|
||||
noOptions: 'No options found',
|
||||
noLocationsAvailable: 'No locations available',
|
||||
chooseFirst: 'Choose a location first',
|
||||
subLocation: 'Sub-location',
|
||||
loading: 'Loading locations...',
|
||||
loadFailed: 'Could not load locations',
|
||||
},
|
||||
|
||||
support: {
|
||||
@@ -245,6 +351,40 @@ export const en = {
|
||||
a4: 'You will see an "Action required" notice on the application. Provide the requested document or detail to resume the review.',
|
||||
},
|
||||
},
|
||||
|
||||
waiver: {
|
||||
title: 'Maritime Waiver',
|
||||
subtitle: 'Apply for a waiver when Ethiopian Shipping & Logistics cannot carry your shipment. Approval issues the bank waiver letter.',
|
||||
preWaiver: {
|
||||
title: 'Pre-waiver',
|
||||
body: 'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
|
||||
apply: 'Apply for a pre-waiver',
|
||||
},
|
||||
postWaiver: {
|
||||
title: 'Post-waiver',
|
||||
body: 'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
|
||||
apply: 'Apply for a post-waiver',
|
||||
},
|
||||
oneShipmentNotice: 'Each waiver covers one shipment, identified by its bill of lading. A second application quoting the same bill of lading is refused while the first is live.',
|
||||
inProgress: 'Applications in progress',
|
||||
issuedLetters: 'Issued waiver letters',
|
||||
emptyLetters: 'No waiver letters issued yet.',
|
||||
columns: {
|
||||
reference: 'Reference',
|
||||
kind: 'Kind',
|
||||
issued: 'Issued',
|
||||
status: 'Status',
|
||||
},
|
||||
letter: 'Letter',
|
||||
fetchFailed: 'Could not fetch the letter',
|
||||
licenseStatus: {
|
||||
ACTIVE: 'Active',
|
||||
EXPIRED: 'Expired',
|
||||
SUSPENDED: 'Suspended',
|
||||
CANCELLED: 'Cancelled',
|
||||
SUPERSEDED: 'Superseded',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { AppShell } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { AppShell } from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconArrowsExchange,
|
||||
IconBell,
|
||||
@@ -14,15 +14,19 @@ import {
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useDispatch } from 'react-redux';
|
||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem } from '@ema-platform/ui';
|
||||
import { BrandMark, logout } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppSelector } from '../store/hooks';
|
||||
} from "@tabler/icons-react";
|
||||
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||
import { useMemo } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useDispatch } from "react-redux";
|
||||
import { notify, AppHeader, AppSidebar } from "@ema-platform/ui";
|
||||
import type { NavItem } from "@ema-platform/ui";
|
||||
import { BrandMark, logout } from "@ema-platform/auth";
|
||||
import { baseApi, useGetUnseenNotificationsQuery } from "@ema-platform/api";
|
||||
import { SUPPORTED_LANGUAGES } from "../i18n/config";
|
||||
import { useAppSelector } from "../store/hooks";
|
||||
|
||||
const BADGE_POLL_MS = 60_000;
|
||||
|
||||
type PortalNavItem = NavItem & { i18nKey: string };
|
||||
|
||||
@@ -35,51 +39,75 @@ type PortalNavItem = NavItem & { i18nKey: string };
|
||||
const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||
{
|
||||
items: [
|
||||
{ to: '/dashboard', label: 'Dashboard', i18nKey: 'nav.dashboard', icon: IconHome2 },
|
||||
{ to: '/notifications', label: 'Notifications', i18nKey: 'nav.notifications', icon: IconBell },
|
||||
{
|
||||
to: "/dashboard",
|
||||
label: "Dashboard",
|
||||
i18nKey: "nav.dashboard",
|
||||
icon: IconHome2,
|
||||
},
|
||||
{
|
||||
to: "/notifications",
|
||||
label: "Notifications",
|
||||
i18nKey: "nav.notifications",
|
||||
icon: IconBell,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupLicensing',
|
||||
label: "nav.groupLicensing",
|
||||
items: [
|
||||
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
|
||||
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupSeafarer',
|
||||
label: "nav.groupSeafarer",
|
||||
items: [
|
||||
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
|
||||
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', icon: IconList },
|
||||
{ to: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true },
|
||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/endorsements', label: 'Endorsements', i18nKey: 'nav.endorsements', icon: IconRubberStamp },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
label: "nav.groupVessels",
|
||||
items: [
|
||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
||||
{ to: '/vessel-registration/transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange, soon: true },
|
||||
{ to: '/vessel-ownership-transfer', label: 'Ownership Transfer', i18nKey: 'nav.ownershipTransfer', icon: IconArrowsExchange },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupAccount',
|
||||
label: "nav.groupAccount",
|
||||
items: [
|
||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
|
||||
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset },
|
||||
{
|
||||
to: "/documents",
|
||||
label: "My Documents",
|
||||
i18nKey: "nav.documents",
|
||||
icon: IconFolderOpen,
|
||||
},
|
||||
{
|
||||
to: "/profile",
|
||||
label: "Profile",
|
||||
i18nKey: "nav.profile",
|
||||
icon: IconUserCircle,
|
||||
},
|
||||
{
|
||||
to: "/support",
|
||||
label: "Help & Support",
|
||||
i18nKey: "nav.support",
|
||||
icon: IconHeadset,
|
||||
},
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
|
||||
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
||||
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||
'/vessel-ownership-transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||
'/licensing/applications': { i18nKey: 'nav.myApplications' },
|
||||
'/waiver': { i18nKey: 'nav.waiver' },
|
||||
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
|
||||
@@ -102,14 +130,32 @@ export function PortalLayout() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
|
||||
const { data: unseen } = useGetUnseenNotificationsQuery(undefined, {
|
||||
pollingInterval: BADGE_POLL_MS,
|
||||
refetchOnMountOrArgChange: false,
|
||||
});
|
||||
|
||||
const sections = useMemo(
|
||||
() =>
|
||||
NAV_SECTIONS.map((section) => ({
|
||||
label: section.label,
|
||||
items: section.items.map(({ i18nKey, ...rest }) => ({
|
||||
...rest,
|
||||
label: t(i18nKey),
|
||||
badge:
|
||||
rest.to === "/notifications" && unseen?.count ? unseen.count : undefined,
|
||||
})),
|
||||
})),
|
||||
[t, unseen?.count],
|
||||
);
|
||||
|
||||
// Breadcrumb trail
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
const segments = location.pathname.split("/").filter(Boolean);
|
||||
const crumbs = [
|
||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
||||
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||
...segments
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => PAGE_META[path] && path !== '/dashboard')
|
||||
.map((_, i) => "/" + segments.slice(0, i + 1).join("/"))
|
||||
.filter((path) => PAGE_META[path] && path !== "/dashboard")
|
||||
.map((path) => ({ label: t(PAGE_META[path].i18nKey), path })),
|
||||
];
|
||||
|
||||
@@ -126,28 +172,34 @@ export function PortalLayout() {
|
||||
|
||||
const handleLogout = () => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
dispatch(baseApi.util.resetApiState());
|
||||
navigate("/login");
|
||||
};
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const displayName = user?.name?.en || user?.username || "";
|
||||
const initials = displayName
|
||||
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '?';
|
||||
? displayName
|
||||
.split(/\s+/)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: 74 }}
|
||||
navbar={{
|
||||
width: sidebarCollapsed ? 72 : 264,
|
||||
breakpoint: 'sm',
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !navOpened },
|
||||
}}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
background: "var(--mantine-color-body)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<AppHeader
|
||||
@@ -157,32 +209,31 @@ export function PortalLayout() {
|
||||
breadcrumbs={crumbs}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName || t('app.name')}
|
||||
userName={displayName || t("app.name")}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
onNotificationsClick={() => navigate("/notifications")}
|
||||
notificationCount={unseen?.count}
|
||||
/>
|
||||
</AppShell.Header>
|
||||
|
||||
<AppShell.Navbar
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'width 200ms ease',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
navItems={NAV_SECTIONS.map((section) => ({
|
||||
label: section.label,
|
||||
items: section.items.map(({ i18nKey, ...rest }) => ({ ...rest, label: t(i18nKey) })),
|
||||
}))}
|
||||
navItems={sections}
|
||||
collapsed={sidebarCollapsed}
|
||||
activePath={location.pathname}
|
||||
onToggleCollapse={toggleSidebar}
|
||||
onNavigate={go}
|
||||
brandName={t('app.name')}
|
||||
brandSubtitle={t('app.authority')}
|
||||
brandName={t("app.name")}
|
||||
brandSubtitle={t("app.authority")}
|
||||
brandLogo={<BrandMark size={32} />}
|
||||
/>
|
||||
</AppShell.Navbar>
|
||||
|
||||
@@ -1,74 +1,92 @@
|
||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
||||
import { I18nextProvider } from 'react-i18next';
|
||||
import { i18n } from './i18n/config';
|
||||
import { PortalLayout } from './layouts/PortalLayout';
|
||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
||||
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||
import { I18nextProvider } from "react-i18next";
|
||||
import { i18n } from "./i18n/config";
|
||||
import { PortalLayout } from "./layouts/PortalLayout";
|
||||
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||
|
||||
// Auth (standalone pages, no portal chrome)
|
||||
import { LoginPage, SignupPage, OTPVerificationPage, ForgotPasswordPage, SetPasswordPage } from '@ema-platform/auth';
|
||||
import {
|
||||
LoginPage,
|
||||
SignupPage,
|
||||
OTPVerificationPage,
|
||||
ForgotPasswordPage,
|
||||
SetPasswordPage,
|
||||
} from "@ema-platform/auth";
|
||||
|
||||
// Portal feature pages
|
||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
||||
import { RequireOperations } from './features/onboarding/components/RequireOperations';
|
||||
import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage';
|
||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
||||
import { SupportPage } from './features/support/pages/SupportPage';
|
||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
||||
import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage';
|
||||
import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage';
|
||||
import { ExamsPage } from './features/exams/pages/ExamsPage';
|
||||
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
|
||||
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||
|
||||
// Phase 1 pages
|
||||
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
||||
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
|
||||
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
|
||||
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
|
||||
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
||||
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
|
||||
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
|
||||
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
|
||||
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
||||
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
|
||||
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
|
||||
import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage';
|
||||
import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage';
|
||||
import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage';
|
||||
import { PaymentFailurePage } from './features/payments/pages/PaymentFailurePage';
|
||||
import { LicenseApplicationPage } from './features/licensing/pages/LicenseApplicationPage';
|
||||
import { WaiverPage } from './features/waiver/pages/WaiverPage';
|
||||
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||
import { VesselRegistrationPage } from "./features/vessel-registration/pages/VesselRegistrationPage";
|
||||
import { VesselTransferPage } from "./features/vessel-registration/pages/VesselTransferPage";
|
||||
import { MyApplicationsPage } from "./features/licensing/pages/MyApplicationsPage";
|
||||
import { PaymentCheckPage } from "./features/payments/pages/PaymentCheckPage";
|
||||
import { PaymentSuccessPage } from "./features/payments/pages/PaymentSuccessPage";
|
||||
import { PaymentFailurePage } from "./features/payments/pages/PaymentFailurePage";
|
||||
import { LicenseApplicationPage } from "./features/licensing/pages/LicenseApplicationPage";
|
||||
import { ApplicationRedirectPage } from "./features/licensing/pages/ApplicationRedirectPage";
|
||||
import { WaiverPage } from "./features/waiver/pages/WaiverPage";
|
||||
|
||||
import { VesselRegistrationStatusPage } from "./features/vessel-registration/pages/VesselRegistrationStatusPage";
|
||||
|
||||
export const router = createBrowserRouter([
|
||||
// Public auth pages
|
||||
{ path: '/login', element: <LoginPage /> },
|
||||
{ path: '/signup', element: <SignupPage /> },
|
||||
{ path: "/login", element: <LoginPage /> },
|
||||
{ path: "/signup", element: <SignupPage /> },
|
||||
|
||||
// Public certificate verification — the target of every printed QR code.
|
||||
// No auth: a verifier scanning a certificate has no portal account.
|
||||
{ path: '/verify', element: <VerifyCertificatePage /> },
|
||||
{ path: '/verify/:code', element: <VerifyCertificatePage /> },
|
||||
{ path: "/verify", element: <VerifyCertificatePage /> },
|
||||
{ path: "/verify/:code", element: <VerifyCertificatePage /> },
|
||||
|
||||
// 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.
|
||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
||||
{ path: '/reset-password', element: <SetPasswordPage /> },
|
||||
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||
{ path: "/reset-password", element: <SetPasswordPage /> },
|
||||
|
||||
// Protected auth pages
|
||||
{
|
||||
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
|
||||
path: '/otp-verify',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<OTPVerificationPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
path: "/otp-verify",
|
||||
},
|
||||
{
|
||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
||||
path: '/forgot-password',
|
||||
element: (
|
||||
<ProtectedRoute>
|
||||
<ForgotPasswordPage />
|
||||
</ProtectedRoute>
|
||||
),
|
||||
path: "/forgot-password",
|
||||
},
|
||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
||||
// profile details are collected where they are actually needed: on /profile,
|
||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
||||
// as a redirect so existing bookmarks and emailed links do not 404.
|
||||
{ path: '/profile-setup', element: <Navigate to="/profile" replace /> },
|
||||
{ path: "/profile-setup", element: <Navigate to="/profile" replace /> },
|
||||
|
||||
// Portal — protected.
|
||||
{
|
||||
@@ -84,89 +102,204 @@ export const router = createBrowserRouter([
|
||||
</ProtectedRoute>
|
||||
),
|
||||
children: [
|
||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/dashboard', element: <DashboardPage /> },
|
||||
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
|
||||
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: "/dashboard", element: <DashboardPage /> },
|
||||
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||
|
||||
// Config-driven licensing: one set of pages serves every licence type.
|
||||
{ path: '/licensing/applications', element: <MyApplicationsPage /> },
|
||||
{ path: "/licensing/applications", element: <MyApplicationsPage /> },
|
||||
|
||||
// Telebirr returns the applicant to these.
|
||||
{ path: '/payments/check', element: <PaymentCheckPage /> },
|
||||
{ path: '/payments/success', element: <PaymentSuccessPage /> },
|
||||
{ path: '/payments/failure', element: <PaymentFailurePage /> },
|
||||
{ path: '/licensing/:typeCode/apply', element: <LicenseApplicationPage /> },
|
||||
{ path: "/payments/check", element: <PaymentCheckPage /> },
|
||||
{ path: "/payments/success", element: <PaymentSuccessPage /> },
|
||||
{ path: "/payments/failure", element: <PaymentFailurePage /> },
|
||||
{
|
||||
path: '/licensing/:typeCode/applications/:applicationId',
|
||||
path: "/licensing/:typeCode/apply",
|
||||
element: (
|
||||
<RequireSeafarerProfile>
|
||||
<LicenseApplicationPage />
|
||||
</RequireSeafarerProfile>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/licensing/:typeCode/applications/:applicationId",
|
||||
element: <LicenseApplicationPage />,
|
||||
},
|
||||
// Notification / email deep links arrive as /applications/<id>; resolve the
|
||||
// licence type and forward to the canonical route.
|
||||
{
|
||||
path: "/applications/:applicationId",
|
||||
element: <ApplicationRedirectPage />,
|
||||
},
|
||||
|
||||
// Seafarer
|
||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
||||
{ path: '/seafarer/records', element: <MySeaRecordsPage /> },
|
||||
{ path: '/exams', element: <ExamsPage /> },
|
||||
{
|
||||
path: "/seafarer-registration",
|
||||
element: (
|
||||
<RequireSeafarerProfile>
|
||||
<SeafarerRegistrationPage />
|
||||
</RequireSeafarerProfile>
|
||||
),
|
||||
},
|
||||
{ path: "/seafarer/records", element: <MySeaRecordsPage /> },
|
||||
{ path: "/exams", element: <ExamsPage /> },
|
||||
// The public-facing registry was a hardcoded mock and does not belong in
|
||||
// the applicant portal; officers browse seafarers in the backoffice.
|
||||
{ path: '/seafarer-registry', element: <Navigate to="/seafarer-registration" replace /> },
|
||||
{ path: '/seafarer-registry/:id', element: <Navigate to="/seafarer-registration" replace /> },
|
||||
{
|
||||
path: "/seafarer-registry",
|
||||
element: <Navigate to="/seafarer-registration" replace />,
|
||||
},
|
||||
{
|
||||
path: "/seafarer-registry/:id",
|
||||
element: <Navigate to="/seafarer-registration" replace />,
|
||||
},
|
||||
|
||||
// Phase 1
|
||||
{ path: '/documents', element: <DocumentVaultPage /> },
|
||||
{ path: '/seaman-book', element: <SeamanBookPage /> },
|
||||
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
|
||||
{ path: '/notifications', element: <NotificationsPage /> },
|
||||
{ path: "/documents", element: <DocumentVaultPage /> },
|
||||
{ path: "/seaman-book", element: <SeamanBookPage /> },
|
||||
{ path: "/seaman-book/apply", element: <SeamanBookApplicationPage /> },
|
||||
{ path: "/notifications", element: <NotificationsPage /> },
|
||||
|
||||
// Phase 2 — CoC / CoP
|
||||
{ path: '/certificates', element: <CertificatesPage /> },
|
||||
{ path: '/certificates/apply', element: <CoCApplicationPage /> },
|
||||
{ path: "/certificates", element: <CertificatesPage /> },
|
||||
{ path: "/certificates/apply", element: <CoCApplicationPage /> },
|
||||
|
||||
// Phase 3 — Endorsement
|
||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
||||
{ path: "/endorsements", element: <EndorsementPage /> },
|
||||
{ path: "/vessel-registration", element: <VesselRegistrationPage /> },
|
||||
// The registration wizard is the config-driven licensing flow; the old
|
||||
// standalone wizard posted to endpoints that never existed.
|
||||
{ path: '/vessel-registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
||||
{ path: '/vessel-registration-dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
||||
{
|
||||
path: "/vessel-registration/apply",
|
||||
element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/vessel-registration-dashboard",
|
||||
element: <Navigate to="/vessel-registration" replace />,
|
||||
},
|
||||
{ path: "/vessel-ownership-transfer", element: <VesselTransferPage /> },
|
||||
// The nav item used to nest this under /vessel-registration, which made
|
||||
// the sidebar's prefix-match (nav-utils.ts isItemActive) light up both
|
||||
// items at once. Kept as a redirect for old bookmarks/links.
|
||||
{
|
||||
path: "/vessel-registration/transfer",
|
||||
element: <Navigate to="/vessel-ownership-transfer" replace />,
|
||||
},
|
||||
// Legacy per-licence-type URLs. Each once had its own hand-written page
|
||||
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
||||
// and dropped every uploaded document on the floor. They are kept as
|
||||
// redirects so old bookmarks land somewhere real; the config-driven
|
||||
// wizard below serves every licence type from one place.
|
||||
{ path: '/logistics-dashboard', element: <Navigate to="/dashboard" replace /> },
|
||||
{ path: '/freight-forwarder-license', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/freight-forwarder-license/apply', element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" replace /> },
|
||||
{ path: '/freight-forwarder-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/shipping-agent-license', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/shipping-agent-license/apply', element: <Navigate to="/licensing/SHIPPING_AGENT/apply" replace /> },
|
||||
{ path: '/shipping-agent-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/combined-license', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/combined-license/apply', element: <Navigate to="/licensing/COMBINED_SA_FF/apply" replace /> },
|
||||
{ path: '/combined-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/joint-investment-license', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/joint-investment-license/apply', element: <Navigate to="/licensing/JOINT_INVESTOR/apply" replace /> },
|
||||
{ path: '/joint-investment-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/mto-license', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{ path: '/mto-license/apply', element: <Navigate to="/licensing/MULTIMODAL_TRANSPORT_OPERATOR/apply" replace /> },
|
||||
{ path: '/mto-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
||||
{
|
||||
path: "/logistics-dashboard",
|
||||
element: <Navigate to="/dashboard" replace />,
|
||||
},
|
||||
{
|
||||
path: "/freight-forwarder-license",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/freight-forwarder-license/apply",
|
||||
element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/freight-forwarder-license/:id/renew",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/shipping-agent-license",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/shipping-agent-license/apply",
|
||||
element: <Navigate to="/licensing/SHIPPING_AGENT/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/shipping-agent-license/:id/renew",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/combined-license",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/combined-license/apply",
|
||||
element: <Navigate to="/licensing/COMBINED_SA_FF/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/combined-license/:id/renew",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/joint-investment-license",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/joint-investment-license/apply",
|
||||
element: <Navigate to="/licensing/JOINT_INVESTOR/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/joint-investment-license/:id/renew",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/mto-license",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
{
|
||||
path: "/mto-license/apply",
|
||||
element: (
|
||||
<Navigate
|
||||
to="/licensing/MULTIMODAL_TRANSPORT_OPERATOR/apply"
|
||||
replace
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
path: "/mto-license/:id/renew",
|
||||
element: <Navigate to="/licensing/applications" replace />,
|
||||
},
|
||||
// Waiver has no backend yet, so it says so rather than pretending.
|
||||
{ path: '/waiver', element: <WaiverPage /> },
|
||||
{ path: '/waiver/apply', element: <Navigate to="/waiver" replace /> },
|
||||
{ path: "/waiver", element: <WaiverPage /> },
|
||||
{ path: "/waiver/apply", element: <Navigate to="/waiver" replace /> },
|
||||
|
||||
// Vessel Registration
|
||||
{ path: "/vessel-registrations", element: <VesselRegistrationPage /> },
|
||||
// { path: '/vessel-registrations/apply', element: <VesselRegistrationPage /> },
|
||||
{
|
||||
path: "/vessel-registrations/:id",
|
||||
element: <VesselRegistrationStatusPage />,
|
||||
},
|
||||
|
||||
// General
|
||||
{ path: '/profile', element: <ProfilePage /> },
|
||||
{ path: '/support', element: <SupportPage /> },
|
||||
{ path: "/profile", element: <ProfilePage /> },
|
||||
{ path: "/support", element: <SupportPage /> },
|
||||
],
|
||||
},
|
||||
|
||||
// The separate vessel-owner login/portal was mock-only and called auth
|
||||
// endpoints that never existed; vessel owners are ordinary portal users.
|
||||
{ path: '/vessel-owner/login', element: <Navigate to="/login" replace /> },
|
||||
{ path: '/vessel-owner/register', element: <Navigate to="/signup" replace /> },
|
||||
{ path: '/vessel-owner/dashboard', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-owner/registration', element: <Navigate to="/vessel-registration" replace /> },
|
||||
{ path: '/vessel-owner/registration/apply', element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace /> },
|
||||
{ path: '/vessel-owner/registration/transfer', element: <Navigate to="/vessel-registration/transfer" replace /> },
|
||||
{ path: "/vessel-owner/login", element: <Navigate to="/login" replace /> },
|
||||
{
|
||||
path: "/vessel-owner/register",
|
||||
element: <Navigate to="/signup" replace />,
|
||||
},
|
||||
{
|
||||
path: "/vessel-owner/dashboard",
|
||||
element: <Navigate to="/vessel-registration" replace />,
|
||||
},
|
||||
{
|
||||
path: "/vessel-owner/registration",
|
||||
element: <Navigate to="/vessel-registration" replace />,
|
||||
},
|
||||
{
|
||||
path: "/vessel-owner/registration/apply",
|
||||
element: <Navigate to="/licensing/VESSEL_REGISTRATION/apply" replace />,
|
||||
},
|
||||
{
|
||||
path: "/vessel-owner/registration/transfer",
|
||||
element: <Navigate to="/vessel-ownership-transfer" replace />,
|
||||
},
|
||||
|
||||
{ path: '*', element: <Navigate to="/" replace /> },
|
||||
{ path: "*", element: <Navigate to="/" replace /> },
|
||||
]);
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
|
||||
import { configureStore } from "@reduxjs/toolkit";
|
||||
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
||||
import {
|
||||
authReducer,
|
||||
signupReducer,
|
||||
@@ -7,17 +7,23 @@ import {
|
||||
authStorage,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
} from '@ema-platform/auth';
|
||||
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||
setToken,
|
||||
} from "@ema-platform/auth";
|
||||
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
||||
|
||||
configureAuthStorage('ema-portal');
|
||||
configureAuthStorage("ema-portal", true);
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
const user = authStorage.getUser<AuthUser>();
|
||||
const profile = authStorage.getProfile<CurrentProfile>();
|
||||
if (token && user) {
|
||||
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
|
||||
return {
|
||||
token,
|
||||
user,
|
||||
isAuthenticated: true,
|
||||
currentProfile: profile ?? null,
|
||||
};
|
||||
}
|
||||
return undefined;
|
||||
})();
|
||||
@@ -34,10 +40,17 @@ export const store = configureStore({
|
||||
});
|
||||
|
||||
configureTokenRefresh({
|
||||
onTokenExpired: refreshAccessToken,
|
||||
onTokenExpired: async () => {
|
||||
const token = await refreshAccessToken();
|
||||
|
||||
store.dispatch(setToken(token));
|
||||
|
||||
return token;
|
||||
},
|
||||
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = '/login';
|
||||
window.location.href = "/login";
|
||||
},
|
||||
});
|
||||
|
||||
|
||||
@@ -93,6 +93,22 @@ export const portalTheme = createTheme({
|
||||
Textarea: { defaultProps: { radius: 'md' } },
|
||||
Select: { defaultProps: { radius: 'md' } },
|
||||
PasswordInput: { defaultProps: { radius: 'md' } },
|
||||
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
||||
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
||||
// taller than the viewport just gets clipped with no way to scroll it.
|
||||
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
||||
Modal: {
|
||||
styles: {
|
||||
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||
},
|
||||
},
|
||||
Drawer: {
|
||||
styles: {
|
||||
content: { display: 'flex', flexDirection: 'column' },
|
||||
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||
},
|
||||
},
|
||||
},
|
||||
other: {
|
||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import '@mantine/core/styles.css';
|
||||
import '@mantine/dates/styles.css';
|
||||
import '@mantine/notifications/styles.css';
|
||||
import './app/theme/portal.css';
|
||||
|
||||
|
||||
@@ -2,3 +2,41 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||
come from Mantine's dark palette so they track the active color scheme
|
||||
instead of a fixed gray.
|
||||
--------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] * {
|
||||
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
39
docs/vessel-ownership-transfer.md
Normal file
39
docs/vessel-ownership-transfer.md
Normal file
@@ -0,0 +1,39 @@
|
||||
# Vessel Ownership Transfer — mock implementation notes (for API integration)
|
||||
|
||||
Portal (owner-facing) + Backoffice (officer-facing) built as **mock UI only**, matching each app's existing mock-driven review-workflow convention. No backend calls anywhere. This doc exists to make wiring the real API fast — it says exactly what to replace and where.
|
||||
|
||||
## What exists
|
||||
|
||||
### Portal (`apps/portal/src/app/features/vessel-transfer/`)
|
||||
- `mock.ts` — types + `MOCK_VESSELS`, `MOCK_REQUESTS`, `addTransferRequest()`
|
||||
- `pages/VesselTransferPage.tsx` — list/status view (`/vessel-transfers`)
|
||||
- `pages/VesselTransferApplicationPage.tsx` — 4-step submit wizard (`/vessel-transfers/apply`)
|
||||
|
||||
### Backoffice (`apps/backoffice/src/app/features/vessel-transfer/`)
|
||||
- `pages/VesselTransferQueuePage.tsx` — types + `MOCK_REQUESTS`, queue table, stats, quick-view drawer (`/vessel-transfers`)
|
||||
- `pages/VesselTransferReviewPage.tsx` — full review, approve/reject, doc view/download (`/vessel-transfers/:id`)
|
||||
|
||||
Each app defines its **own separate mock array** (codebase convention: no shared domain types in `libs/`). They are not the same objects and don't sync — submitting in portal does not appear in the backoffice queue, and vice versa. That's the main thing a real API fixes.
|
||||
|
||||
## Data shape mismatch to resolve
|
||||
|
||||
Portal's `TransferRequest.currentOwner` / `newOwnerName` are plain strings. Backoffice's `TransferRequest.currentOwner` / `newOwner` are full objects (`{ name, idOrTin, phone, email, address }`). The real API should standardize on the **backoffice shape** — it's the superset the officer review page needs.
|
||||
|
||||
**Known gap:** `VesselTransferApplicationPage.tsx`'s wizard collects `newOwnerIdOrTin`, `newOwnerPhone`, `newOwnerEmail`, `newOwnerAddress` and shows them in the review step, but `addTransferRequest()` only persists `newOwnerName` — the rest are captured in UI state and silently dropped on submit. When wiring the real POST, send all of them (the fields already exist in component state, just not in the payload today).
|
||||
|
||||
## Suggested API surface
|
||||
|
||||
- `GET /vessels?ownerId=:id&status=approved` — portal wizard's vessel `<Select>` (replaces `MOCK_VESSELS.filter(v => v.approved)` in `VesselTransferApplicationPage.tsx`).
|
||||
- `POST /vessel-transfers` — submit. Body: `vesselId, newOwner: {name, idOrTin, phone, email, address}, reason, document (file upload)`. Replaces `addTransferRequest()` in portal's `mock.ts`.
|
||||
- `GET /vessel-transfers?ownerId=:id` — portal's "My Transfer Requests" list (`VesselTransferPage.tsx`).
|
||||
- `GET /vessel-transfers` (officer, all + filters) — backoffice queue (`VesselTransferQueuePage.tsx`); search/status-filter can move server-side or stay client-side over the fetched page.
|
||||
- `GET /vessel-transfers/:id` — backoffice review page and portal status detail both key off this.
|
||||
- `PATCH /vessel-transfers/:id` — officer action. Body: `{ status: 'Under Review' | 'Approved' | 'Rejected', remarks? }`. Replaces the in-place `record.status = ...` mutation in `VesselTransferReviewPage.tsx`. Backend should enforce remarks-required-on-Rejected (UI already gates this, but don't trust client-only validation).
|
||||
- Document storage: real upload + signed URL for View, and a real download endpoint — today `getDocUrl()` in both `CoCReviewPage`-style `DocViewer`s and this feature's `VesselTransferReviewPage.tsx` fake it with a hardcoded base64 PDF / placehold.co image.
|
||||
- SMS/email: triggered server-side on submit and on every status change — UI currently just shows a toast claiming this happened (`notify.success('... SMS and email ...')`); no actual send anywhere.
|
||||
|
||||
## Where to swap mock for real calls
|
||||
|
||||
- Portal: `apps/portal/src/app/features/vessel-transfer/mock.ts` (whole file), plus the `useState`/local reads of `MOCK_VESSELS`/`MOCK_REQUESTS` in both page files.
|
||||
- Backoffice: the exported `MOCK_REQUESTS`/types block at the top of `VesselTransferQueuePage.tsx`, plus the direct `record.status = ...` mutation in `VesselTransferReviewPage.tsx` (swap for a mutation call + refetch/cache-invalidate).
|
||||
- Both apps already have an RTK Query base (`@ema-platform/api` → `baseApi.injectEndpoints`, see `apps/portal/.../payment/api/payment-api.ts` or `apps/backoffice/.../certification/api/certification-api.ts`) — follow that pattern rather than the ad-hoc `useApiQuery`/`useApiMutation` escape hatch.
|
||||
73
docs/vessel-registration.md
Normal file
73
docs/vessel-registration.md
Normal file
@@ -0,0 +1,73 @@
|
||||
# Vessel Registration — mock implementation notes (for API integration)
|
||||
|
||||
Portal (owner-facing) + Backoffice (officer/manager-facing) built as **mock UI only**, matching each app's existing mock-driven review-workflow convention (same pattern as `docs/vessel-ownership-transfer.md`). No backend calls anywhere. This doc exists to make wiring the real API fast — it says exactly what to replace and where.
|
||||
|
||||
## Workflow
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
A["1. Vessel owner opens Vessel Registration"] --> B{"2. Select vessel category"}
|
||||
B -->|Inland Waterway Vessel| C["3. Enter vessel details"]
|
||||
B -->|Sea-going Vessel International| C
|
||||
C --> D["4. Enter technical & ownership details"]
|
||||
D --> E["5. Upload required documents"]
|
||||
E --> F["6. Review application"]
|
||||
F --> G["7. Submit application"]
|
||||
G --> H["8. System sends confirmation notification\n(SMS + email)"]
|
||||
H --> I["9. Officer reviews application\nin registration queue"]
|
||||
I --> J{"10. Officer takes action"}
|
||||
J -->|Under Review| I
|
||||
J -->|Correction Required| K["11. Owner updates & resubmits"]
|
||||
K --> I
|
||||
J -->|Rejected| Z1["Terminal: Rejected\n(remarks visible to owner)"]
|
||||
J -->|Approved| L["12. System generates certificates\n(1 for Inland, 4 for Sea-going)"]
|
||||
L --> M["13. Owner downloads certificates"]
|
||||
M --> N["14. System tracks renewal status\n(OK / Due Soon / Overdue)"]
|
||||
N --> O["15. Manager views registration reports"]
|
||||
```
|
||||
|
||||
Status lifecycle: `Pending → Under Review → (Correction Required → Resubmitted →)* → Approved | Rejected`. `Approved`/`Rejected` are terminal — no further officer action possible once reached.
|
||||
|
||||
## What exists
|
||||
|
||||
### Portal (`apps/portal/src/app/features/vessel-registration/`)
|
||||
- `mock.ts` — types (`VesselCategory`, `RegistrationStatus`, `VesselRegistration`, …), reference data (`VESSEL_TYPES`, `ENGINE_TYPES`, `HULL_MATERIALS`, `REQUIRED_DOCS`, `CERTIFICATES` per category), `MOCK_REGISTRATIONS`, `addRegistration()`, `recordDownload()`
|
||||
- `pages/VesselRegistrationPage.tsx` — list of the owner's registrations (`/vessel-registrations`)
|
||||
- `pages/VesselRegistrationApplicationPage.tsx` — 5-step submit wizard: Category → Vessel Details → Technical & Ownership → Documents → Review (`/vessel-registrations/apply`)
|
||||
- `pages/VesselRegistrationStatusPage.tsx` — status detail: timeline, renewal alert, officer remarks + resubmit, certificate downloads (`/vessel-registrations/:id`)
|
||||
|
||||
### Backoffice (`apps/backoffice/src/app/features/vessel-registration/`)
|
||||
- `mock.ts` — separate types + `MOCK_REGISTRATIONS` (officer-side shape, includes `documents[]` and `correctionFields?`), `applyDecision()`, `generateCertificates()`
|
||||
- `pages/VesselRegistrationQueuePage.tsx` — queue table, stats, search + 3 filters (status/category/renewal) (`/vessel-registrations`)
|
||||
- `pages/VesselRegistrationReviewPage.tsx` — two-column review: vessel/technical/ownership info + documents/timeline/remarks/certificates; decision bar (Mark Under Review / Request Correction / Reject / Approve) (`/vessel-registrations/:id`)
|
||||
- `pages/VesselRegistrationReportPage.tsx` — manager report: KPI cards, status distribution, vessel-type breakdown, recent-10 table, renewal-tracking table (`/vessel-registration-report`)
|
||||
|
||||
Each app defines its **own separate mock array** (codebase convention: no shared domain types in `libs/`), and they don't sync — submitting in portal does not appear in the backoffice queue. That's the main thing a real API fixes. The two `VesselRegistration` shapes are already close (both modeled on the same field set) but not identical — see below.
|
||||
|
||||
## Data shape mismatch to resolve
|
||||
|
||||
- **Portal `VesselRegistration`** has no `documents[]` array — uploaded files live only as transient `File[]` in the wizard's component state (`docs: Record<string, File[] | null>` in `VesselRegistrationApplicationPage.tsx`) and are **never persisted** to the mock record; only file names are shown in the review step. **Known gap**: when wiring the real POST, the upload payload needs to be sent and stored — today it's dropped after submit.
|
||||
- **Backoffice `VesselRegistration`** has `documents: RegistrationDocument[]` (`{key, label, fileName, fileType}`) so the officer review page has something to show — this is invented/seeded mock data, not real uploads. The real API should standardize on the **backoffice shape** (documents as first-class persisted records) since it's the superset the officer review page needs.
|
||||
- **Certificates**: portal's `RegistrationCertificate` has a `downloads` counter (incremented client-side via `recordDownload()`); backoffice's does not track downloads. Real API should own download-count as a server-side audit log, not a client counter.
|
||||
- **Correction targeting**: only the backoffice shape has `correctionFields?: string[]` (officer picks which fields/docs need fixing via a `MultiSelect`). Portal has no corresponding "these are the fields you need to fix" UI on the status page beyond the free-text `remarks` — worth adding when the real API returns `correctionFields`, so the owner can be pointed at the exact fields.
|
||||
- **Status enum**: portal has 5 statuses (`Pending | Under Review | Correction Required | Approved | Rejected`); backoffice has 6 (adds `Resubmitted`, distinct from `Correction Required`, for after the owner has acted). The real API should use the backoffice's 6-value enum — portal's status page should render `Resubmitted` (currently unhandled — it'll fall through to no special UI).
|
||||
|
||||
## Suggested API surface
|
||||
|
||||
- `POST /vessel-registrations` — submit. Multipart body: all wizard fields (see `VesselRegistration` in either `mock.ts` for the full field list) + document files keyed by the `REQUIRED_DOCS[category]` slot key. Replaces `addRegistration()` in portal's `mock.ts`.
|
||||
- `GET /vessel-registrations?ownerId=:id` — portal's "My Registrations" list (`VesselRegistrationPage.tsx`).
|
||||
- `GET /vessel-registrations/:id` — used by portal's status page and backoffice's review page alike (both key off the same id).
|
||||
- `GET /vessel-registrations` (officer, all + filters: `status`, `category`, `renewal`, `q`) — backoffice queue (`VesselRegistrationQueuePage.tsx`); filtering can move server-side or stay client-side over the fetched page as today.
|
||||
- `PATCH /vessel-registrations/:id` — officer decision. Body: `{ status: 'Under Review' | 'Correction Required' | 'Rejected' | 'Approved', remarks?: string, correctionFields?: string[] }`. Replaces `applyDecision()` in backoffice's `mock.ts`. Backend should enforce remarks-required for `Correction Required`/`Rejected` (UI already gates this client-side, but don't trust it alone).
|
||||
- `POST /vessel-registrations/:id/resubmit` — owner resubmit after correction (portal's "Resubmit Application" button on `VesselRegistrationStatusPage.tsx`, currently just routes back to the wizard with no state carried over — real flow should prefill the wizard from the existing record and only require the flagged fields/docs).
|
||||
- On `PATCH .../:id` with `status: 'Approved'`: backend generates certificate records per `CERTIFICATES[category]` (1 for Inland, 4 for Sea-going) — replaces `generateCertificates()` in backoffice's `mock.ts`.
|
||||
- `GET /vessel-registrations/:id/certificates/:certId/download` — real file download + audit log entry, replacing the shared `DEMO_PDF` base64 placeholder used by both apps' download buttons.
|
||||
- `GET /vessel-registration-reports/summary` — KPIs + status distribution + type breakdown for `VesselRegistrationReportPage.tsx` (or compute client-side from a full `GET /vessel-registrations` if volume stays low — current mock computes everything client-side from the in-memory array).
|
||||
- Document storage: real upload + signed URL for View, and a real download endpoint — today `getDocUrl()` in `VesselRegistrationReviewPage.tsx` (backoffice) fakes it with a hardcoded base64 PDF / placehold.co image, same pattern as the vessel-transfer feature.
|
||||
- SMS/email: triggered server-side on submit and on every status change — UI currently just shows a toast claiming this happened (`notify.success('... SMS and email ...')`); no actual send anywhere in either app.
|
||||
|
||||
## Where to swap mock for real calls
|
||||
|
||||
- Portal: `apps/portal/src/app/features/vessel-registration/mock.ts` (whole file), plus the `useState`/local reads of `MOCK_REGISTRATIONS` in all three page files, and the transient `File[]` state in `VesselRegistrationApplicationPage.tsx` (needs to become a real multipart upload on submit).
|
||||
- Backoffice: `apps/backoffice/src/app/features/vessel-registration/mock.ts` (whole file), plus the direct `applyDecision(record, ...)` / `generateCertificates(record)` mutations in `VesselRegistrationReviewPage.tsx` (swap for a mutation call + refetch/cache-invalidate), and the client-side aggregation in `VesselRegistrationReportPage.tsx` (swap for the summary endpoint above, or keep as a derived selector over cached query data).
|
||||
- Both apps already have an RTK Query base (`@ema-platform/api` → `baseApi.injectEndpoints`, see `apps/portal/src/app/features/payment/api/payment-api.ts` or `apps/backoffice/src/app/features/certification/api/certification-api.ts`) — follow that pattern rather than the ad-hoc `useApiQuery`/`useApiMutation` escape hatch.
|
||||
@@ -1,9 +1,9 @@
|
||||
import { createApi } from '@reduxjs/toolkit/query/react';
|
||||
import { baseQueryWithReauth } from './base-query-with-reauth';
|
||||
|
||||
import { createApi } from "@reduxjs/toolkit/query/react";
|
||||
import { baseQueryWithReauth } from "./base-query-with-reauth";
|
||||
import { tagTypes } from "./tagTypes";
|
||||
export const baseApi = createApi({
|
||||
reducerPath: 'baseApi',
|
||||
reducerPath: "baseApi",
|
||||
baseQuery: baseQueryWithReauth,
|
||||
tagTypes: ['Api'],
|
||||
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
|
||||
endpoints: () => ({}),
|
||||
});
|
||||
|
||||
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const tagTypes = ["ProfessionApi"];
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './licensing.types';
|
||||
export * from './licensing-api';
|
||||
export * from './licensing.helpers';
|
||||
export * from './use-localized';
|
||||
|
||||
@@ -2,6 +2,7 @@ import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
LicenseStatus,
|
||||
ValidationIssue,
|
||||
} from './licensing.types';
|
||||
@@ -129,10 +130,34 @@ export const TERMINAL_STATUSES: LicenseStatus[] = [
|
||||
'REJECTED',
|
||||
];
|
||||
|
||||
/** Licence types with no `companyName` — these are filed by a person, not a
|
||||
* business, so display falls back to the applicant name captured in the form. */
|
||||
export const APPLICANT_NAME_TYPE_KEYS = [
|
||||
'SEAFARER_REGISTRATION',
|
||||
'CERTIFICATE_OF_COMPETENCY',
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
|
||||
/** Company name, or applicant name for licence types that have no company. */
|
||||
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
|
||||
if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) {
|
||||
return app.companyName ?? undefined;
|
||||
}
|
||||
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
|
||||
?.applicantName;
|
||||
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
|
||||
}
|
||||
|
||||
/** Reads a bilingual value for the active language, falling back to English. */
|
||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||
if (!value) return '';
|
||||
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
|
||||
// `||` not `??`: an empty Amharic string is "not translated", not a value —
|
||||
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
|
||||
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -223,6 +248,9 @@ export function buildWizardSteps(
|
||||
* instead of showing an empty page.
|
||||
*/
|
||||
hasStaff?: boolean;
|
||||
/** Active UI language. Components get this from `useLocalized`; this is a
|
||||
* pure function, so the caller passes `i18n.language` through. */
|
||||
language?: string;
|
||||
},
|
||||
): WizardStep[] {
|
||||
const visible = [...sections]
|
||||
@@ -237,7 +265,7 @@ export function buildWizardSteps(
|
||||
if (!group) {
|
||||
steps.push({
|
||||
key: `section:${section.key}`,
|
||||
label: localized(section.title),
|
||||
label: localized(section.title, options?.language),
|
||||
kind: 'sections',
|
||||
sections: [section],
|
||||
});
|
||||
@@ -292,6 +320,7 @@ export type FieldErrors = Record<string, string>;
|
||||
export function validateSections(
|
||||
sections: FormSectionConfig[],
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
language = 'en',
|
||||
): FieldErrors {
|
||||
const errors: FieldErrors = {};
|
||||
|
||||
@@ -315,8 +344,8 @@ export function validateSections(
|
||||
if (field.required && empty) {
|
||||
errors[`${section.key}.${field.key}`] =
|
||||
field.type === 'BOOLEAN'
|
||||
? `${localized(field.label)} must be accepted`
|
||||
: `${localized(field.label)} is required`;
|
||||
? `${localized(field.label, language)} must be accepted`
|
||||
: `${localized(field.label, language)} is required`;
|
||||
continue;
|
||||
}
|
||||
if (empty) continue;
|
||||
|
||||
@@ -7,35 +7,35 @@ export type Bilingual = { en?: string; am?: string };
|
||||
* previously this lived in a backoffice mock page.
|
||||
*/
|
||||
export type LicenseStatus =
|
||||
| 'DRAFT'
|
||||
| 'SUBMITTED'
|
||||
| 'UNDER_REVIEW'
|
||||
| 'UNDER_EVALUATION'
|
||||
| 'RESUBMIT_REQUIRED'
|
||||
| 'INSPECTION_PENDING'
|
||||
| 'INSPECTION_COMPLETED'
|
||||
| 'APPROVED'
|
||||
| 'REJECTED'
|
||||
| 'ON_HOLD'
|
||||
| 'PAYMENT_PENDING'
|
||||
| 'PAID'
|
||||
| 'PAYMENT_CONFIRMED'
|
||||
| 'CERTIFICATE_ISSUED'
|
||||
| 'COMPLETED';
|
||||
| "DRAFT"
|
||||
| "SUBMITTED"
|
||||
| "UNDER_REVIEW"
|
||||
| "UNDER_EVALUATION"
|
||||
| "RESUBMIT_REQUIRED"
|
||||
| "INSPECTION_PENDING"
|
||||
| "INSPECTION_COMPLETED"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "ON_HOLD"
|
||||
| "PAYMENT_PENDING"
|
||||
| "PAID"
|
||||
| "PAYMENT_CONFIRMED"
|
||||
| "CERTIFICATE_ISSUED"
|
||||
| "COMPLETED";
|
||||
|
||||
export type ApplicationKind = 'NEW' | 'RENEWAL';
|
||||
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||
|
||||
export type FormFieldType =
|
||||
| 'TEXT'
|
||||
| 'TEXTAREA'
|
||||
| 'NUMBER'
|
||||
| 'MONEY'
|
||||
| 'DATE'
|
||||
| 'SELECT'
|
||||
| 'BOOLEAN'
|
||||
| 'EMAIL'
|
||||
| 'PHONE'
|
||||
| 'TIN';
|
||||
| "TEXT"
|
||||
| "TEXTAREA"
|
||||
| "NUMBER"
|
||||
| "MONEY"
|
||||
| "DATE"
|
||||
| "SELECT"
|
||||
| "BOOLEAN"
|
||||
| "EMAIL"
|
||||
| "PHONE"
|
||||
| "TIN";
|
||||
|
||||
export interface FieldCondition {
|
||||
field: string;
|
||||
@@ -138,7 +138,7 @@ export interface DocumentRequirement {
|
||||
name: Bilingual;
|
||||
description?: Bilingual;
|
||||
applicationKind: ApplicationKind;
|
||||
mode: 'ALWAYS' | 'CONDITIONAL' | 'OPTIONAL';
|
||||
mode: "ALWAYS" | "CONDITIONAL" | "OPTIONAL";
|
||||
conditionExpression?: FieldCondition & { previousDocExpired?: string };
|
||||
allowedMimeTypes: string[];
|
||||
maxSizeMb: number;
|
||||
@@ -245,7 +245,7 @@ export interface StatusHistoryEntry {
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export type RemarkTargetType = 'FORM_SECTION' | 'DOCUMENT' | 'STAFF';
|
||||
export type RemarkTargetType = "FORM_SECTION" | "DOCUMENT" | "STAFF";
|
||||
|
||||
export interface ApplicationRemark {
|
||||
id: string;
|
||||
@@ -276,8 +276,8 @@ export interface Inspection {
|
||||
scheduledDate: string | null;
|
||||
conductedDate: string | null;
|
||||
location: string | null;
|
||||
status: 'SCHEDULED' | 'COMPLETED' | 'CANCELLED';
|
||||
result: 'PASSED' | 'FAILED' | null;
|
||||
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
|
||||
result: "PASSED" | "FAILED" | null;
|
||||
findings: string | null;
|
||||
}
|
||||
|
||||
@@ -303,17 +303,18 @@ export interface QueueFilter {
|
||||
submittedTo?: string;
|
||||
overdue?: boolean;
|
||||
sortBy?: QueueSortField;
|
||||
sortDir?: 'ASC' | 'DESC';
|
||||
sortDir?: "ASC" | "DESC";
|
||||
take?: number;
|
||||
skip?: number;
|
||||
}
|
||||
|
||||
export type QueueSortField =
|
||||
| 'submittedAt'
|
||||
| 'applicationNumber'
|
||||
| 'companyName'
|
||||
| 'status'
|
||||
| 'dueAt';
|
||||
| "submittedAt"
|
||||
| "applicationNumber"
|
||||
| "companyName"
|
||||
| "status"
|
||||
| "dueAt"
|
||||
| "claimedAt";
|
||||
|
||||
/** Row counts behind the queue's saved-view tabs. */
|
||||
export interface QueueCounts {
|
||||
@@ -325,7 +326,7 @@ export interface QueueCounts {
|
||||
all: number;
|
||||
}
|
||||
|
||||
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
|
||||
export type DocumentDecision = "ACCEPTED" | "REJECTED";
|
||||
|
||||
/** An officer's verdict on one uploaded document. */
|
||||
export interface DocumentReview {
|
||||
@@ -363,10 +364,10 @@ export interface ExportResult {
|
||||
truncated: boolean;
|
||||
}
|
||||
|
||||
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
||||
export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||
|
||||
export interface TemplatePageOptions {
|
||||
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
|
||||
format?: "A4" | "A5" | "Letter" | "Legal";
|
||||
landscape?: boolean;
|
||||
printBackground?: boolean;
|
||||
}
|
||||
@@ -398,7 +399,7 @@ export interface Paginated<T> {
|
||||
|
||||
/** Per-field problems returned by the server when a submission is incomplete. */
|
||||
export interface ValidationIssue {
|
||||
kind: 'field' | 'document' | 'staff';
|
||||
kind: "field" | "document" | "staff";
|
||||
target: string;
|
||||
field?: string;
|
||||
message: string;
|
||||
@@ -406,7 +407,7 @@ export interface ValidationIssue {
|
||||
|
||||
/** What the browser must do to complete a payment. */
|
||||
export interface ClientAction {
|
||||
type: 'REDIRECT' | 'LAUNCH_APP' | 'NONE';
|
||||
type: "REDIRECT" | "LAUNCH_APP" | "NONE";
|
||||
url?: string;
|
||||
appId?: string;
|
||||
receiveCode?: string;
|
||||
@@ -414,12 +415,7 @@ export interface ClientAction {
|
||||
}
|
||||
|
||||
export type PaymentStatus =
|
||||
| 'PENDING'
|
||||
| 'PROCESSING'
|
||||
| 'PAID'
|
||||
| 'FAILED'
|
||||
| 'EXPIRED'
|
||||
| 'CANCELLED';
|
||||
"PENDING" | "PROCESSING" | "PAID" | "FAILED" | "EXPIRED" | "CANCELLED";
|
||||
|
||||
export interface InitiatePaymentResult {
|
||||
paymentId: string;
|
||||
@@ -461,7 +457,7 @@ export interface IssuedLicense {
|
||||
tinNumber: string | null;
|
||||
issueDate: string;
|
||||
expiryDate: string;
|
||||
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
|
||||
status: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED";
|
||||
/**
|
||||
* Days until the expiry date; negative once it has passed. Computed by the
|
||||
* API in the authority's timezone — the client must not re-derive it, since
|
||||
|
||||
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal file
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import { useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { localized } from './licensing.helpers';
|
||||
import type { Bilingual } from './licensing.types';
|
||||
|
||||
/**
|
||||
* The component-facing bilingual reader — the twin of `useDateDisplayer()`.
|
||||
*
|
||||
* A hook rather than a bare `localized` import so the calling component is
|
||||
* subscribed to i18next: switching language re-renders it and every
|
||||
* backend-configured label flips with the rest of the UI. `useTranslation()`
|
||||
* with no instance argument resolves to the app's own <I18nextProvider>
|
||||
* (portal router.tsx, backoffice AppProviders.tsx), which is what makes this
|
||||
* work across two separate i18n instances.
|
||||
*
|
||||
* DISPLAY ONLY. Code that *matches* on a label — `.includes('nationality')`,
|
||||
* the vessel-picker regex in ConfigDrivenSection, the SUBCITY/WOREDA test in
|
||||
* AddressFormContent — must keep reading `value.en`, or the match breaks the
|
||||
* moment the user switches language.
|
||||
*
|
||||
* The returned function is stable per language, so it is safe — and required —
|
||||
* as a useMemo/useCallback dependency.
|
||||
*/
|
||||
export function useLocalized(): (value: Bilingual | undefined) => string {
|
||||
const { i18n } = useTranslation();
|
||||
return useCallback((value) => localized(value, i18n.language), [i18n.language]);
|
||||
}
|
||||
105
libs/api/src/lib/file-upload/index.ts
Normal file
105
libs/api/src/lib/file-upload/index.ts
Normal file
@@ -0,0 +1,105 @@
|
||||
import { useCallback, useState } from "react";
|
||||
import { baseApi } from "../base-api";
|
||||
import { resolveTokenFromStorage } from "../session";
|
||||
|
||||
export interface UploadFileInfo {
|
||||
bucket: string;
|
||||
fileName: string;
|
||||
contentType: string;
|
||||
originalname: string;
|
||||
size: number;
|
||||
}
|
||||
|
||||
export interface UploadKeyResponse {
|
||||
fileInfo: UploadFileInfo;
|
||||
presigned: string;
|
||||
}
|
||||
|
||||
const fileUploadApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getFileUploadKey: builder.mutation<
|
||||
UploadKeyResponse,
|
||||
{ endpoint: string; file: File }
|
||||
>({
|
||||
query: ({ endpoint, file }) => ({
|
||||
url: endpoint,
|
||||
method: "POST",
|
||||
body: {
|
||||
fileName: file.name,
|
||||
contentType: file.type || "application/octet-stream",
|
||||
size: file.size,
|
||||
originalname: file.name,
|
||||
},
|
||||
}),
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useGetFileUploadKeyMutation } = fileUploadApi;
|
||||
|
||||
export const STORAGE_UPLOAD_ERROR = "STORAGE_UPLOAD_ERROR";
|
||||
export const STORAGE_UPLOAD_TOO_LARGE = "STORAGE_UPLOAD_TOO_LARGE";
|
||||
|
||||
export function isStorageUploadError(error: unknown): error is Error & {
|
||||
message: typeof STORAGE_UPLOAD_ERROR | typeof STORAGE_UPLOAD_TOO_LARGE;
|
||||
} {
|
||||
return (
|
||||
error instanceof Error &&
|
||||
(error.message === STORAGE_UPLOAD_ERROR ||
|
||||
error.message === STORAGE_UPLOAD_TOO_LARGE)
|
||||
);
|
||||
}
|
||||
|
||||
// Bare fetch on purpose: this PUT targets the presigned storage URL directly, not
|
||||
// VITE_BASE_API_URL, so it must not go through baseApi/fetchBaseQuery.
|
||||
async function uploadToPresigned(
|
||||
file: File,
|
||||
presignedUrl: string,
|
||||
): Promise<void> {
|
||||
if (!presignedUrl) throw new Error(STORAGE_UPLOAD_ERROR);
|
||||
|
||||
const token = resolveTokenFromStorage();
|
||||
const res = await fetch(presignedUrl, {
|
||||
method: "PUT",
|
||||
body: file,
|
||||
headers: {
|
||||
"Content-Type": file.type || "application/octet-stream",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
});
|
||||
|
||||
if (res.status === 413) throw new Error(STORAGE_UPLOAD_TOO_LARGE);
|
||||
if (!res.ok) throw new Error(STORAGE_UPLOAD_ERROR);
|
||||
}
|
||||
|
||||
export function useDocumentUpload() {
|
||||
const [getFileUploadKey, mutationState] = useGetFileUploadKeyMutation();
|
||||
// Spans both steps (key request + presigned PUT) — mutationState.isLoading alone
|
||||
// would drop to false once the key request resolves, before the file PUT finishes.
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
|
||||
const upload = useCallback(
|
||||
async (file: File, endpoint = "/documents/get-file-upload-key"): Promise<UploadKeyResponse> => {
|
||||
setIsUploading(true);
|
||||
try {
|
||||
const { presigned, fileInfo } = await getFileUploadKey({
|
||||
endpoint,
|
||||
file,
|
||||
}).unwrap();
|
||||
await uploadToPresigned(file, presigned);
|
||||
return { presigned, fileInfo };
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
},
|
||||
[getFileUploadKey],
|
||||
);
|
||||
|
||||
return {
|
||||
upload,
|
||||
isUploading,
|
||||
error: mutationState.error,
|
||||
reset: mutationState.reset,
|
||||
};
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user