mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-09-07 09:35:46 +00:00
12
README.md
12
README.md
@@ -37,8 +37,8 @@ emaui/
|
|||||||
```
|
```
|
||||||
|
|
||||||
### libs/api
|
### libs/api
|
||||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or localStorage.
|
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
|
||||||
- `session/` — `resolveTokenFromStorage()` reads from `localStorage` keys or `auth-token` cookie. `resolveSessionContext()` merges Redux state token with storage fallback.
|
- `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.
|
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
|
||||||
|
|
||||||
### libs/ui
|
### libs/ui
|
||||||
@@ -55,10 +55,10 @@ emaui/
|
|||||||
|
|
||||||
1. User submits the login form (LoginForm / LoginPage).
|
1. User submits the login form (LoginForm / LoginPage).
|
||||||
2. The form calls the `login` RTK Query mutation (backoffice) or a plain `fetch` (portal).
|
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.
|
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`.
|
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 localStorage keys.
|
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_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
| `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 |
|
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
| `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,
|
useGetLicenseTemplatesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetTemplateVariablesQuery,
|
useGetTemplateVariablesQuery,
|
||||||
|
useLocalized,
|
||||||
usePublishLicenseTemplateMutation,
|
usePublishLicenseTemplateMutation,
|
||||||
useUpdateLicenseValidityMutation,
|
useUpdateLicenseValidityMutation,
|
||||||
useUpdateLicenseTemplateMutation,
|
useUpdateLicenseTemplateMutation,
|
||||||
type LicenseTemplate,
|
type LicenseTemplate,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
import { PERMISSIONS } from '../../../layouts/nav-config';
|
||||||
|
|
||||||
@@ -70,6 +71,7 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
|||||||
*/
|
*/
|
||||||
export function CertificateDesignerPage() {
|
export function CertificateDesignerPage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
|
|
||||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||||
@@ -226,7 +228,7 @@ export function CertificateDesignerPage() {
|
|||||||
label={t('designer.licenceType', 'Licence type')}
|
label={t('designer.licenceType', 'Licence type')}
|
||||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||||
value: type.id,
|
value: type.id,
|
||||||
label: type.name?.en ?? type.key,
|
label: localized(type.name) || type.key,
|
||||||
}))}
|
}))}
|
||||||
value={typeId}
|
value={typeId}
|
||||||
onChange={(value) => {
|
onChange={(value) => {
|
||||||
@@ -529,7 +531,7 @@ export function CertificateDesignerPage() {
|
|||||||
'Starts from the live design, or the built-in layout if this type has none.',
|
'Starts from the live design, or the built-in layout if this type has none.',
|
||||||
)}
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -550,7 +552,7 @@ export function CertificateDesignerPage() {
|
|||||||
>
|
>
|
||||||
{t('designer.create', 'Create')}
|
{t('designer.create', 'Create')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -4,22 +4,19 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Paper,
|
Card,
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, type AdvancedColumn } from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
useGetCertificationsQuery,
|
useGetCertificationsQuery,
|
||||||
useCreateCertificationMutation,
|
useCreateCertificationMutation,
|
||||||
@@ -55,27 +52,29 @@ function CertificationForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="sm">
|
<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.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 />
|
<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.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} />
|
<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 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>
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CertificationPage() {
|
export function CertificationPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
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 [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||||
const [deleteCert] = useDeleteCertificationMutation();
|
const [deleteCert] = useDeleteCertificationMutation();
|
||||||
@@ -104,8 +103,8 @@ export function CertificationPage() {
|
|||||||
notify.success(t('certification.created'));
|
notify.success(t('certification.created'));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('certification.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -116,14 +115,49 @@ export function CertificationPage() {
|
|||||||
notify.success(t('certification.deleted'));
|
notify.success(t('certification.deleted'));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('certification.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
@@ -147,57 +181,28 @@ export function CertificationPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('certification.columns.name')}</Table.Th>
|
tableName={t('certification.title')}
|
||||||
<Table.Th>{t('certification.columns.description')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('certification.columns.status')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th />
|
onPageChange={setPageIndex}
|
||||||
</Table.Tr>
|
pageSize={pageSize}
|
||||||
</Table.Thead>
|
onPageSizeChange={setPageSize}
|
||||||
<Table.Tbody>
|
refresh={refetch}
|
||||||
{certifications.map((cert) => (
|
isLoading={isFetching}
|
||||||
<Table.Tr key={cert.id}>
|
emptyText={t('certification.noItems')}
|
||||||
<Table.Td><Text fz="sm" fw={500}>{cert.name[locale]}</Text></Table.Td>
|
/>
|
||||||
<Table.Td>
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,38 +1,40 @@
|
|||||||
import { baseApi } from '@ema-platform/api';
|
import { baseApi } from "@ema-platform/api";
|
||||||
import type {
|
import type {
|
||||||
Organization,
|
Organization,
|
||||||
Profession,
|
Profession,
|
||||||
ListResponse,
|
ListResponse,
|
||||||
CreateProfessionPayload,
|
CreateProfessionPayload,
|
||||||
UpdateProfessionPayload,
|
UpdateProfessionPayload,
|
||||||
} from '../types/configuration';
|
} from "../types/configuration";
|
||||||
|
|
||||||
const configurationApi = baseApi.injectEndpoints({
|
const configurationApi = baseApi.injectEndpoints({
|
||||||
endpoints: (builder) => ({
|
endpoints: (builder) => ({
|
||||||
getOrganizations: builder.query<ListResponse<Organization>, void>({
|
getOrganizations: builder.query<ListResponse<Organization>, void>({
|
||||||
query: () => '/organizations',
|
query: () => "/organizations",
|
||||||
providesTags: ['Api'],
|
providesTags: ["Api"],
|
||||||
}),
|
}),
|
||||||
|
|
||||||
getProfessions: builder.query<ListResponse<Profession>, void>({
|
getProfessions: builder.query<ListResponse<Profession>, string>({
|
||||||
query: () => '/professions',
|
query: (params) => ({
|
||||||
providesTags: ['Api'],
|
url: `/professions?q=${encodeURIComponent(params)}`,
|
||||||
|
}),
|
||||||
|
providesTags: ["Api", "backOfficeApi", "ProfessionApi"],
|
||||||
}),
|
}),
|
||||||
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
|
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
|
||||||
query: (body) => ({ url: '/professions', method: 'POST', body }),
|
query: (body) => ({ url: "/professions", method: "POST", body }),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ["Api"],
|
||||||
}),
|
}),
|
||||||
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
|
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
|
||||||
query: ({ id, ...body }) => ({
|
query: ({ id, ...body }) => ({
|
||||||
url: `/professions/${id}`,
|
url: `/professions/${id}`,
|
||||||
method: 'PUT',
|
method: "PUT",
|
||||||
body,
|
body,
|
||||||
}),
|
}),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ["Api"],
|
||||||
}),
|
}),
|
||||||
deleteProfession: builder.mutation<void, string>({
|
deleteProfession: builder.mutation<void, string>({
|
||||||
query: (id) => ({ url: `/professions/${id}`, method: 'DELETE' }),
|
query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }),
|
||||||
invalidatesTags: ['Api'],
|
invalidatesTags: ["Api"],
|
||||||
}),
|
}),
|
||||||
}),
|
}),
|
||||||
overrideExisting: true,
|
overrideExisting: true,
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState, useEffect, useCallback } from 'react';
|
import { useState, useEffect, useCallback } from "react";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
@@ -7,31 +7,44 @@ import {
|
|||||||
Button,
|
Button,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Table,
|
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
Select,
|
Select,
|
||||||
Paper,
|
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Alert,
|
Alert,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useForm } from '@mantine/form';
|
import { useForm } from "@mantine/form";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
import {
|
||||||
import { useTranslation } from 'react-i18next';
|
IconEdit,
|
||||||
import { notify } from '@ema-platform/ui';
|
IconTrash,
|
||||||
import { LocationPage } from '../../location/pages/LocationPage';
|
IconPlus,
|
||||||
import { CertificationPage } from '../../certification/pages/CertificationPage';
|
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 {
|
import {
|
||||||
useGetOrganizationsQuery,
|
useGetOrganizationsQuery,
|
||||||
useGetProfessionsQuery,
|
useGetProfessionsQuery,
|
||||||
useCreateProfessionMutation,
|
useCreateProfessionMutation,
|
||||||
useUpdateProfessionMutation,
|
useUpdateProfessionMutation,
|
||||||
useDeleteProfessionMutation,
|
useDeleteProfessionMutation,
|
||||||
} from '../api/configuration-api';
|
} from "../api/configuration-api";
|
||||||
import type { Profession } from '../types/configuration';
|
import type { Profession } from "../types/configuration";
|
||||||
|
|
||||||
interface ProfFormValues {
|
interface ProfFormValues {
|
||||||
nameEn: string;
|
nameEn: string;
|
||||||
@@ -49,14 +62,27 @@ interface ProfFormProps {
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
|
function ProfessionForm({
|
||||||
|
editingProf,
|
||||||
|
deptOptions,
|
||||||
|
isSubmitting,
|
||||||
|
onSubmit,
|
||||||
|
onCancel,
|
||||||
|
}: ProfFormProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const form = useForm<ProfFormValues>({
|
const form = useForm<ProfFormValues>({
|
||||||
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
|
initialValues: {
|
||||||
|
nameEn: "",
|
||||||
|
nameAm: "",
|
||||||
|
descEn: "",
|
||||||
|
descAm: "",
|
||||||
|
departmentId: "",
|
||||||
|
},
|
||||||
validate: {
|
validate: {
|
||||||
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
|
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||||
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
|
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||||
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
|
departmentId: (v) =>
|
||||||
|
!v ? t("configuration.validation.departmentRequired") : null,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -65,90 +91,125 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa
|
|||||||
form.setValues({
|
form.setValues({
|
||||||
nameEn: editingProf.name.en,
|
nameEn: editingProf.name.en,
|
||||||
nameAm: editingProf.name.am,
|
nameAm: editingProf.name.am,
|
||||||
descEn: editingProf.description.en ?? '',
|
descEn: editingProf.description.en ?? "",
|
||||||
descAm: editingProf.description.am ?? '',
|
descAm: editingProf.description.am ?? "",
|
||||||
departmentId: editingProf.departmentId,
|
departmentId: editingProf.departmentId,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}, [editingProf]);
|
}, [editingProf]);
|
||||||
|
|
||||||
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
|
const handleSubmit = form.onSubmit((values) =>
|
||||||
|
onSubmit(values, !!editingProf),
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('configuration.nameEn')}
|
label={t("configuration.nameEn")}
|
||||||
placeholder="English name"
|
placeholder="English name"
|
||||||
{...form.getInputProps('nameEn')}
|
{...form.getInputProps("nameEn")}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('configuration.nameAm')}
|
label={t("configuration.nameAm")}
|
||||||
placeholder="የአማርኛ ስም"
|
placeholder="የአማርኛ ስም"
|
||||||
{...form.getInputProps('nameAm')}
|
{...form.getInputProps("nameAm")}
|
||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t('configuration.descEn')}
|
label={t("configuration.descEn")}
|
||||||
placeholder="English description"
|
placeholder="English description"
|
||||||
{...form.getInputProps('descEn')}
|
{...form.getInputProps("descEn")}
|
||||||
size="sm"
|
size="sm"
|
||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label={t('configuration.descAm')}
|
label={t("configuration.descAm")}
|
||||||
placeholder="የአማርኛ መግለጫ"
|
placeholder="የአማርኛ መግለጫ"
|
||||||
{...form.getInputProps('descAm')}
|
{...form.getInputProps("descAm")}
|
||||||
size="sm"
|
size="sm"
|
||||||
autosize
|
autosize
|
||||||
minRows={2}
|
minRows={2}
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
label={t('configuration.department')}
|
label={t("configuration.department")}
|
||||||
placeholder={t('configuration.selectDepartment')}
|
placeholder={t("configuration.selectDepartment")}
|
||||||
data={deptOptions}
|
data={deptOptions}
|
||||||
{...form.getInputProps('departmentId')}
|
{...form.getInputProps("departmentId")}
|
||||||
size="sm"
|
size="sm"
|
||||||
searchable
|
searchable
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onCancel} size="sm">
|
<Button variant="default" onClick={onCancel} size="sm">
|
||||||
{t('configuration.cancel')}
|
{t("configuration.cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||||
{editingProf ? t('configuration.update') : t('configuration.create')}
|
{editingProf
|
||||||
|
? t("configuration.update")
|
||||||
|
: t("configuration.create")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function ProfessionTab() {
|
function ProfessionTab() {
|
||||||
const { t, i18n } = useTranslation();
|
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: deptRes } = useGetOrganizationsQuery();
|
||||||
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
|
const { pageIndex, setPageIndex, setQ, pageSize, setPageSize, skip, take } =
|
||||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
useServerTable({
|
||||||
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
|
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 [deleteProfession] = useDeleteProfessionMutation();
|
||||||
|
|
||||||
const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []);
|
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 professions = profRes?.items ?? [];
|
||||||
|
const totalCount = profRes?.total ?? profRes?.count ?? professions.length;
|
||||||
|
|
||||||
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
||||||
const [showProfForm, setShowProfForm] = useState(false);
|
const [showProfForm, setShowProfForm] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
|
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) => ({
|
const deptOptions = departments
|
||||||
value: d.id,
|
.filter((d) => d?.status?.toLowerCase() === "active")
|
||||||
label: d.name?.[locale] ?? d.name ?? '',
|
.map((d) => ({
|
||||||
}));
|
value: d.id,
|
||||||
|
label: d.name?.[locale] ?? d.name ?? "",
|
||||||
|
}));
|
||||||
|
|
||||||
const resetProfForm = useCallback(() => {
|
const resetProfForm = useCallback(() => {
|
||||||
setEditingProf(null);
|
setEditingProf(null);
|
||||||
@@ -160,67 +221,136 @@ function ProfessionTab() {
|
|||||||
setShowProfForm(true);
|
setShowProfForm(true);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleDeleteProf = useCallback((prof: Profession) => {
|
const handleDeleteProf = useCallback(
|
||||||
setDeleteTarget(prof);
|
(prof: Profession) => {
|
||||||
openDelete();
|
setDeleteTarget(prof);
|
||||||
}, [openDelete]);
|
openDelete();
|
||||||
|
},
|
||||||
|
[openDelete],
|
||||||
|
);
|
||||||
|
|
||||||
const confirmDeleteProf = useCallback(async () => {
|
const confirmDeleteProf = useCallback(async () => {
|
||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
await deleteProfession(deleteTarget.id).unwrap();
|
await deleteProfession(deleteTarget.id).unwrap();
|
||||||
notify.success(t('configuration.deleted'));
|
notify.success(t("configuration.deleted"));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('configuration.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
}, [deleteTarget, deleteProfession, closeDelete, t]);
|
}, [deleteTarget, deleteProfession, closeDelete, handleError]);
|
||||||
|
|
||||||
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
|
const handleProfSubmit = useCallback(
|
||||||
const name = { en: values.nameEn, am: values.nameAm };
|
async (values: ProfFormValues) => {
|
||||||
const description = { en: values.descEn, am: values.descAm };
|
const name = { en: values.nameEn, am: values.nameAm };
|
||||||
|
const description = { en: values.descEn, am: values.descAm };
|
||||||
|
|
||||||
try {
|
try {
|
||||||
if (editingProf) {
|
if (editingProf) {
|
||||||
await updateProfession({
|
await updateProfession({
|
||||||
id: editingProf.id,
|
id: editingProf.id,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
departmentId: values.departmentId,
|
departmentId: values.departmentId,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
notify.success(t('configuration.updated'));
|
notify.success(t("configuration.updated"));
|
||||||
} else {
|
} else {
|
||||||
await createProfession({
|
await createProfession({
|
||||||
departmentId: values.departmentId,
|
departmentId: values.departmentId,
|
||||||
name,
|
name,
|
||||||
description,
|
description,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
notify.success(t('configuration.created'));
|
notify.success(t("configuration.created"));
|
||||||
|
}
|
||||||
|
resetProfForm();
|
||||||
|
} catch (e) {
|
||||||
|
handleError(e);
|
||||||
}
|
}
|
||||||
resetProfForm();
|
},
|
||||||
} catch {
|
[
|
||||||
notify.error(t('configuration.error'));
|
editingProf,
|
||||||
}
|
createProfession,
|
||||||
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
|
updateProfession,
|
||||||
|
resetProfForm,
|
||||||
|
handleError,
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
const getDeptName = useCallback((deptId: string) => {
|
const getDeptName = useCallback(
|
||||||
const dept = departments.find((d) => d.id === deptId);
|
(deptId: string) => {
|
||||||
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
|
const dept = departments.find((d) => d.id === deptId);
|
||||||
}, [departments, locale]);
|
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) {
|
if (isLoading) {
|
||||||
return <Center py="xl"><Loader /></Center>;
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isError) {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<Group justify="space-between" align="flex-end" mb="md">
|
<Group justify="space-between" align="flex-end" mb="md">
|
||||||
<Title order={2}>{t('configuration.professionsList')}</Title>
|
<Title order={2}>{t("configuration.professionsList")}</Title>
|
||||||
{!showProfForm && (
|
{!showProfForm && (
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
@@ -228,7 +358,7 @@ function ProfessionTab() {
|
|||||||
onClick={() => setShowProfForm(true)}
|
onClick={() => setShowProfForm(true)}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
{t('configuration.addProfession')}
|
{t("configuration.addProfession")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -243,55 +373,40 @@ function ProfessionTab() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead>
|
columns={professionColumns}
|
||||||
<Table.Tr>
|
data={professions}
|
||||||
<Table.Th>{t('configuration.name')}</Table.Th>
|
tableName={t("configuration.professionsList")}
|
||||||
<Table.Th>{t('configuration.description')}</Table.Th>
|
itemCount={totalCount}
|
||||||
<Table.Th>{t('configuration.department')}</Table.Th>
|
pageIndex={pageIndex}
|
||||||
<Table.Th />
|
onPageChange={setPageIndex}
|
||||||
</Table.Tr>
|
pageSize={pageSize}
|
||||||
</Table.Thead>
|
onPageSizeChange={setPageSize}
|
||||||
<Table.Tbody>
|
refresh={refetch}
|
||||||
{professions.filter((p) => p.isActive).map((prof) => (
|
onSearchChange={setQ}
|
||||||
<Table.Tr key={prof.id}>
|
isLoading={isFetching}
|
||||||
<Table.Td>{prof.name[locale]}</Table.Td>
|
emptyText={t("configuration.noProfessions")}
|
||||||
<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>
|
|
||||||
|
|
||||||
<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">
|
<Text mb="md">
|
||||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.[locale] ?? '' })}
|
{t("configuration.deleteConfirmText", {
|
||||||
|
name: deleteTarget?.name?.[locale] ?? "",
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">
|
||||||
<Button color="red" onClick={confirmDeleteProf} size="sm">{t('configuration.delete')}</Button>
|
{t("configuration.cancel")}
|
||||||
</Group>
|
</Button>
|
||||||
|
<Button color="red" onClick={confirmDeleteProf} size="sm">
|
||||||
|
{t("configuration.delete")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
@@ -302,18 +417,24 @@ export function ConfigurationPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Title order={2}>{t('configuration.title')}</Title>
|
<Title order={2}>{t("configuration.title")}</Title>
|
||||||
|
|
||||||
<Tabs defaultValue="professions">
|
<Tabs defaultValue="professions">
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
|
<Tabs.Tab
|
||||||
{t('configuration.professions')}
|
value="professions"
|
||||||
|
leftSection={<IconBriefcase size={16} />}
|
||||||
|
>
|
||||||
|
{t("configuration.professions")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||||
{t('location.title')}
|
{t("location.title")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
|
<Tabs.Tab
|
||||||
{t('certification.title')}
|
value="certifications"
|
||||||
|
leftSection={<IconCertificate size={16} />}
|
||||||
|
>
|
||||||
|
{t("certification.title")}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
|
|||||||
@@ -28,7 +28,8 @@ export interface Profession {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export interface ListResponse<T> {
|
export interface ListResponse<T> {
|
||||||
count: number;
|
count?: number;
|
||||||
|
total?: number;
|
||||||
items: T[];
|
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';
|
} from '@mantine/core';
|
||||||
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
import { IconAlertTriangle, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetExamIncidentsQuery,
|
useGetExamIncidentsQuery,
|
||||||
@@ -51,6 +52,7 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
|||||||
*/
|
*/
|
||||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
const { data: incidents, isError } = useGetExamIncidentsQuery(examId);
|
||||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||||
@@ -175,7 +177,7 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
|||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>
|
<Text fz="xs">{showDate(incident.occurredAt)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { useState } from 'react';
|
import { Dispatch, SetStateAction, useState } from "react";
|
||||||
import {
|
import {
|
||||||
Paper,
|
Paper,
|
||||||
Group,
|
Group,
|
||||||
@@ -10,16 +10,17 @@ import {
|
|||||||
Checkbox,
|
Checkbox,
|
||||||
Box,
|
Box,
|
||||||
Button,
|
Button,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconSearch } from '@tabler/icons-react';
|
import { IconSearch } from "@tabler/icons-react";
|
||||||
import type { QuestionBrief } from '../types/exam';
|
import type { QuestionBrief, actionTypes } from "../types/exam";
|
||||||
|
|
||||||
interface QuestionAssignerProps {
|
interface QuestionAssignerProps {
|
||||||
available: QuestionBrief[];
|
available: QuestionBrief[];
|
||||||
assigned: QuestionBrief[];
|
assigned: QuestionBrief[];
|
||||||
onChange: (assigned: QuestionBrief[]) => void;
|
onChange: (assigned: QuestionBrief[]) => void;
|
||||||
mode?: 'manual' | 'random';
|
mode?: "manual" | "random";
|
||||||
|
actions: Dispatch<SetStateAction<actionTypes | undefined>>;
|
||||||
}
|
}
|
||||||
|
|
||||||
function QuestionList({
|
function QuestionList({
|
||||||
@@ -38,11 +39,13 @@ function QuestionList({
|
|||||||
label: string;
|
label: string;
|
||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as "en" | "am";
|
||||||
const placeholder = t('exam.assigner.search');
|
const placeholder = t("exam.assigner.search");
|
||||||
return (
|
return (
|
||||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
<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">
|
<Paper withBorder radius="md">
|
||||||
<Group p="sm" pb={0}>
|
<Group p="sm" pb={0}>
|
||||||
<TextInput
|
<TextInput
|
||||||
@@ -57,7 +60,9 @@ function QuestionList({
|
|||||||
<ScrollArea h={280} p="sm" pt="xs">
|
<ScrollArea h={280} p="sm" pt="xs">
|
||||||
<Stack gap={4}>
|
<Stack gap={4}>
|
||||||
{items.length === 0 && (
|
{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) => (
|
{items.map((q) => (
|
||||||
<Paper
|
<Paper
|
||||||
@@ -66,19 +71,37 @@ function QuestionList({
|
|||||||
p="xs"
|
p="xs"
|
||||||
radius="sm"
|
radius="sm"
|
||||||
style={{
|
style={{
|
||||||
cursor: 'pointer',
|
cursor: "pointer",
|
||||||
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
|
borderColor: selected.has(q.id)
|
||||||
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
|
? "var(--mantine-color-blue-5)"
|
||||||
|
: undefined,
|
||||||
|
background: selected.has(q.id)
|
||||||
|
? "var(--mantine-color-blue-0)"
|
||||||
|
: undefined,
|
||||||
}}
|
}}
|
||||||
onClick={() => onToggle(q.id)}
|
onClick={() => onToggle(q.id)}
|
||||||
>
|
>
|
||||||
<Group gap="sm" wrap="nowrap">
|
<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 }}>
|
<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}>
|
<Group gap={4} mt={2}>
|
||||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
|
<Badge
|
||||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</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>
|
</Group>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</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 { t } = useTranslation();
|
||||||
const [searchLeft, setSearchLeft] = useState('');
|
const [searchLeft, setSearchLeft] = useState("");
|
||||||
const [searchRight, setSearchRight] = useState('');
|
const [searchRight, setSearchRight] = useState("");
|
||||||
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
||||||
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
|
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
|
||||||
const assignedIds = new Set(assigned.map((q) => q.id));
|
const assignedIds = new Set(assigned.map((q) => q.id));
|
||||||
|
|
||||||
const filteredAvailable = available.filter(
|
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(
|
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 assignSelected = () => {
|
||||||
const toAssign = available.filter((q) => selectedLeft.has(q.id));
|
const toAssign = available.filter((q) => selectedLeft.has(q.id));
|
||||||
onChange([...assigned, ...toAssign]);
|
onChange([...assigned, ...toAssign]);
|
||||||
|
actions("add");
|
||||||
setSelectedLeft(new Set());
|
setSelectedLeft(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
const removeSelected = () => {
|
const removeSelected = () => {
|
||||||
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
||||||
|
actions("remove");
|
||||||
setSelectedRight(new Set());
|
setSelectedRight(new Set());
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
|
{mode === "manual" && (
|
||||||
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
|
<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">
|
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||||
{mode === 'manual' && (
|
{mode === "manual" && (
|
||||||
<QuestionList
|
<QuestionList
|
||||||
items={filteredAvailable}
|
items={filteredAvailable}
|
||||||
selected={selectedLeft}
|
selected={selectedLeft}
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
const next = new Set(selectedLeft);
|
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);
|
setSelectedLeft(next);
|
||||||
}}
|
}}
|
||||||
search={searchLeft}
|
search={searchLeft}
|
||||||
onSearchChange={setSearchLeft}
|
onSearchChange={setSearchLeft}
|
||||||
label={t('exam.assigner.available')}
|
label={t("exam.assigner.available")}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<QuestionList
|
<QuestionList
|
||||||
@@ -141,32 +186,43 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
|
|||||||
selected={selectedRight}
|
selected={selectedRight}
|
||||||
onToggle={(id) => {
|
onToggle={(id) => {
|
||||||
const next = new Set(selectedRight);
|
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);
|
setSelectedRight(next);
|
||||||
}}
|
}}
|
||||||
search={searchRight}
|
search={searchRight}
|
||||||
onSearchChange={setSearchRight}
|
onSearchChange={setSearchRight}
|
||||||
label={t('exam.assigner.assigned')}
|
label={t("exam.assigner.assigned")}
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
{mode === 'manual' && (
|
{mode === "manual" && (
|
||||||
<Group gap="sm" justify="center">
|
<Group gap="sm" justify="center">
|
||||||
{selectedLeft.size > 0 && (
|
{selectedLeft.size > 0 && (
|
||||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||||
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
|
{t("exam.assigner.assignSelected", { count: selectedLeft.size })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{selectedRight.size > 0 && (
|
{selectedRight.size > 0 && (
|
||||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
<Button
|
||||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={removeSelected}
|
||||||
|
>
|
||||||
|
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
{mode === 'random' && selectedRight.size > 0 && (
|
{mode === "random" && selectedRight.size > 0 && (
|
||||||
<Group gap="sm" justify="center">
|
<Group gap="sm" justify="center">
|
||||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
<Button
|
||||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
size="xs"
|
||||||
|
variant="light"
|
||||||
|
color="red"
|
||||||
|
onClick={removeSelected}
|
||||||
|
>
|
||||||
|
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
import { useState, useEffect, useRef, useMemo } from "react";
|
||||||
import { useNavigate, useParams } from 'react-router-dom';
|
import { useNavigate, useParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
@@ -22,9 +22,9 @@ import {
|
|||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Box,
|
Box,
|
||||||
rem,
|
rem,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
IconArrowLeft,
|
IconArrowLeft,
|
||||||
IconPrinter,
|
IconPrinter,
|
||||||
@@ -56,32 +56,50 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
|||||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
|
PENDING: "gray",
|
||||||
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
|
ACTIVE: "blue",
|
||||||
|
COMPLETED: "teal",
|
||||||
|
CANCELLED: "red",
|
||||||
|
POSTPONED: "orange",
|
||||||
|
PUBLISHED: "green",
|
||||||
};
|
};
|
||||||
|
|
||||||
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
|
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
|
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||||
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
|
const ADMIN_LABEL: Record<string, string> = {
|
||||||
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
|
OFFLINE: "Offline",
|
||||||
|
ONLINE: "Online",
|
||||||
|
};
|
||||||
|
const EVAL_LABEL: Record<string, string> = {
|
||||||
|
SUM: "Sum",
|
||||||
|
AVERAGE: "Average",
|
||||||
|
PERCENTAGE: "Percentage",
|
||||||
|
};
|
||||||
|
|
||||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
{label}
|
||||||
|
</Text>
|
||||||
|
<Text fz="sm" fw={500}>
|
||||||
|
{value || "—"}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamDetailPage() {
|
export function ExamDetailPage() {
|
||||||
const { t, i18n } = useTranslation();
|
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 { id } = useParams<{ id: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const printRef = useRef<HTMLDivElement>(null);
|
const printRef = useRef<HTMLDivElement>(null);
|
||||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
const [recordOpened, { open: openRecord, close: closeRecord }] =
|
||||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
useDisclosure(false);
|
||||||
|
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||||
|
useDisclosure(false);
|
||||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||||
const [randomCount, setRandomCount] = useState(5);
|
const [randomCount, setRandomCount] = useState(5);
|
||||||
const [updateExam] = useUpdateExamMutation();
|
const [updateExam] = useUpdateExamMutation();
|
||||||
@@ -109,12 +127,26 @@ export function ExamDetailPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
if (isLoading)
|
||||||
|
return (
|
||||||
|
<Center py="xl">
|
||||||
|
<Loader />
|
||||||
|
</Center>
|
||||||
|
);
|
||||||
if (isError || !exam) {
|
if (isError || !exam) {
|
||||||
return (
|
return (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
|
<Button
|
||||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
|
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>
|
</Stack>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -161,42 +193,51 @@ export function ExamDetailPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handlePrint = async () => {
|
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)) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const printWindow = window.open('', '_blank');
|
const printWindow = window.open("", "_blank");
|
||||||
if (!printWindow) return;
|
if (!printWindow) return;
|
||||||
|
|
||||||
let logoBase64 = '';
|
let logoBase64 = "";
|
||||||
try {
|
try {
|
||||||
const resp = await fetch('/ema-logo.png');
|
const resp = await fetch("/ema-logo.png");
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
logoBase64 = await new Promise<string>((resolve) => {
|
logoBase64 = await new Promise<string>((resolve) => {
|
||||||
const reader = new FileReader();
|
const reader = new FileReader();
|
||||||
reader.onloadend = () => resolve(reader.result as string);
|
reader.onloadend = () => resolve(reader.result as string);
|
||||||
reader.readAsDataURL(blob);
|
reader.readAsDataURL(blob);
|
||||||
});
|
});
|
||||||
} catch { /* logo not available */ }
|
} catch {
|
||||||
|
/* logo not available */
|
||||||
|
}
|
||||||
|
|
||||||
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
|
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
|
||||||
const qHtml = (exam.questions ?? []).map((q, i) => {
|
const qHtml = (exam.questions ?? [])
|
||||||
const full = qMap.get(q.id);
|
.map((q, i) => {
|
||||||
const titleStr = q.title[locale] || q.title.en;
|
const full = qMap.get(q.id);
|
||||||
const descStr = full?.description?.[locale] || full?.description?.en || '';
|
const titleStr = q.title[locale] || q.title.en;
|
||||||
return `
|
const descStr =
|
||||||
|
full?.description?.[locale] || full?.description?.en || "";
|
||||||
|
return `
|
||||||
<div style="margin-bottom: 24px; page-break-inside: avoid;">
|
<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="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>
|
<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>` : ''}
|
${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 === "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('') : ''}
|
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
}).join('');
|
})
|
||||||
|
.join("");
|
||||||
|
|
||||||
printWindow.document.write(`
|
printWindow.document.write(`
|
||||||
<html><head><title>${exam.title[locale] || exam.title.en}</title>
|
<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; } }
|
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||||
</style></head><body>
|
</style></head><body>
|
||||||
<div class="header">
|
<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>
|
<h1>${exam.title[locale] || exam.title.en}</h1>
|
||||||
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
|
<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>
|
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
|
||||||
</div>
|
</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}
|
${qHtml}
|
||||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
<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
|
Generated by EMA — Ethiopian Maritime Authority
|
||||||
@@ -229,15 +270,25 @@ export function ExamDetailPage() {
|
|||||||
setTimeout(() => printWindow.print(), 500);
|
setTimeout(() => printWindow.print(), 500);
|
||||||
};
|
};
|
||||||
|
|
||||||
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
const totalPoints = (exam.questions ?? []).reduce(
|
||||||
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
|
(s, q) => s + Number(q.points),
|
||||||
|
0,
|
||||||
|
);
|
||||||
|
const certName =
|
||||||
|
exam.certification?.name?.[locale] ??
|
||||||
|
certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ??
|
||||||
|
"—";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Stack gap="md" ref={printRef}>
|
<Stack gap="md" ref={printRef}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
|
<ActionIcon
|
||||||
|
variant="subtle"
|
||||||
|
size="lg"
|
||||||
|
onClick={() => navigate("/exams")}
|
||||||
|
>
|
||||||
<IconArrowLeft size={18} />
|
<IconArrowLeft size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
<div>
|
<div>
|
||||||
@@ -245,41 +296,93 @@ export function ExamDetailPage() {
|
|||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
|
<Button
|
||||||
{t('exam.print')}
|
variant="light"
|
||||||
|
leftSection={<IconPrinter size={15} />}
|
||||||
|
onClick={handlePrint}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("exam.print")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
|
<Button
|
||||||
{t('exam.recordResult')}
|
leftSection={<IconPlus size={15} />}
|
||||||
|
onClick={openRecord}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("exam.recordResult")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Status badge */}
|
{/* 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}`)}
|
{t(`exam.status.${exam.status}`)}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{/* Exam Info */}
|
{/* Exam Info */}
|
||||||
<Paper withBorder radius="lg" p="lg">
|
<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">
|
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||||
<InfoRow label={t('exam.detail.certification')} value={certName} />
|
<InfoRow label={t("exam.detail.certification")} value={certName} />
|
||||||
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
|
<InfoRow
|
||||||
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
|
label={t("exam.detail.type")}
|
||||||
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
|
value={t(`exam.type.${exam.type}`)}
|
||||||
<InfoRow label={t('exam.detail.date')} value={exam.date} />
|
/>
|
||||||
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
|
<InfoRow
|
||||||
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
|
label={t("exam.detail.form")}
|
||||||
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
|
value={t(`exam.formType.${exam.form}`)}
|
||||||
<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.venue")} value={exam.venue} />
|
||||||
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
|
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||||
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
|
<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>
|
</SimpleGrid>
|
||||||
{(exam.direction?.en || exam.direction?.am) && (
|
{(exam.direction?.en || exam.direction?.am) && (
|
||||||
<>
|
<>
|
||||||
<Divider my="md" />
|
<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>
|
</Paper>
|
||||||
@@ -287,24 +390,41 @@ export function ExamDetailPage() {
|
|||||||
{/* Questions */}
|
{/* Questions */}
|
||||||
<Paper withBorder radius="lg" p="lg">
|
<Paper withBorder radius="lg" p="lg">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
|
<Title order={5}>
|
||||||
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
|
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||||
{t('exam.manageQuestions')}
|
</Title>
|
||||||
|
<Button
|
||||||
|
variant="light"
|
||||||
|
size="xs"
|
||||||
|
leftSection={<IconPlus size={14} />}
|
||||||
|
onClick={openAssignModal}
|
||||||
|
>
|
||||||
|
{t("exam.manageQuestions")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
{(exam.questions ?? []).length === 0 ? (
|
{(exam.questions ?? []).length === 0 ? (
|
||||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||||
{t('exam.noQuestionsAssigned')}
|
{t("exam.noQuestionsAssigned")}
|
||||||
</Alert>
|
</Alert>
|
||||||
) : (
|
) : (
|
||||||
<Stack gap="md">
|
<Stack gap="md">
|
||||||
{(exam.questions ?? []).map((q, i) => (
|
{(exam.questions ?? []).map((q, i) => (
|
||||||
<Paper key={q.id} withBorder p="md" radius="md">
|
<Paper key={q.id} withBorder p="md" radius="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<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}>
|
<Group gap={4}>
|
||||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
|
<Badge
|
||||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</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>
|
||||||
</Group>
|
</Group>
|
||||||
<Text fz="sm">{q.title[locale]}</Text>
|
<Text fz="sm">{q.title[locale]}</Text>
|
||||||
@@ -321,27 +441,38 @@ export function ExamDetailPage() {
|
|||||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||||
|
|
||||||
{/* Question assignment modal */}
|
{/* 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">
|
<Stack gap="md">
|
||||||
{exam.selectionMethod === 'MANUAL' ? (
|
{exam.selectionMethod === "MANUAL" ? (
|
||||||
<>
|
<>
|
||||||
<QuestionAssigner
|
<QuestionAssigner
|
||||||
available={eligibleQuestions}
|
available={eligibleQuestions}
|
||||||
assigned={draftQuestions}
|
assigned={draftQuestions}
|
||||||
onChange={setDraftQuestions}
|
onChange={setDraftQuestions}
|
||||||
mode="manual"
|
mode="manual"
|
||||||
|
actions={setWhatAction}
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
<Button variant="default" onClick={closeAssign} size="sm">
|
||||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
{t("exam.cancel")}
|
||||||
</Group>
|
</Button>
|
||||||
|
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||||
|
{t("exam.saveAssignments")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<NumberInput
|
<NumberInput
|
||||||
placeholder={t('exam.assigner.selectCount')}
|
placeholder={t("exam.assigner.selectCount")}
|
||||||
value={randomCount}
|
value={randomCount}
|
||||||
onChange={(v) => setRandomCount(Number(v))}
|
onChange={(v) => setRandomCount(Number(v))}
|
||||||
min={1}
|
min={1}
|
||||||
@@ -358,10 +489,14 @@ export function ExamDetailPage() {
|
|||||||
onChange={setDraftQuestions}
|
onChange={setDraftQuestions}
|
||||||
mode="random"
|
mode="random"
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
<Button variant="default" onClick={closeAssign} size="sm">
|
||||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
{t("exam.cancel")}
|
||||||
</Group>
|
</Button>
|
||||||
|
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||||
|
{t("exam.saveAssignments")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,47 +1,51 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from "react";
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
Stack,
|
Stack,
|
||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Textarea,
|
Textarea,
|
||||||
Paper,
|
Card,
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
Tabs,
|
Tabs,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
Divider,
|
Divider,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
import {
|
||||||
import { notify } from '@ema-platform/ui';
|
IconEdit,
|
||||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
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 {
|
import {
|
||||||
useGetExamsQuery,
|
useGetExamsQuery,
|
||||||
useCreateExamMutation,
|
useCreateExamMutation,
|
||||||
useUpdateExamMutation,
|
useUpdateExamMutation,
|
||||||
useDeleteExamMutation,
|
useDeleteExamMutation,
|
||||||
} from '../api/exam-api';
|
} from "../api/exam-api";
|
||||||
import type { Exam } from '../types/exam';
|
import type { Exam } from "../types/exam";
|
||||||
|
|
||||||
const STATUS_COLOR: Record<string, string> = {
|
const STATUS_COLOR: Record<string, string> = {
|
||||||
PENDING: 'gray',
|
PENDING: "gray",
|
||||||
ACTIVE: 'blue',
|
ACTIVE: "blue",
|
||||||
COMPLETED: 'teal',
|
COMPLETED: "teal",
|
||||||
CANCELLED: 'red',
|
CANCELLED: "red",
|
||||||
POSTPONED: 'orange',
|
POSTPONED: "orange",
|
||||||
PUBLISHED: 'green',
|
PUBLISHED: "green",
|
||||||
};
|
};
|
||||||
|
|
||||||
function ExamForm({
|
function ExamForm({
|
||||||
@@ -58,100 +62,297 @@ function ExamForm({
|
|||||||
onCancel: () => void;
|
onCancel: () => void;
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
const [certificationId, setCertificationId] = useState<string | null>(
|
||||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
editing?.certificationId ?? null,
|
||||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
);
|
||||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
|
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
|
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||||
const [date, setDate] = useState(editing?.date ?? '');
|
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 [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||||
const [type, setType] = useState<string | null>(editing?.type ?? null);
|
const [type, setType] = useState<string | null>(editing?.type ?? null);
|
||||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||||
const [venue, setVenue] = useState(editing?.venue ?? '');
|
const [venue, setVenue] = useState(editing?.venue ?? "");
|
||||||
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
|
const [adminMethod, setAdminMethod] = useState<string | null>(
|
||||||
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
|
editing?.administrationMethod ?? null,
|
||||||
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
|
);
|
||||||
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
|
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 [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||||
|
|
||||||
const handleSubmit = (e: React.FormEvent) => {
|
const handleSubmit = (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
|
if (
|
||||||
notify.error('Please fill all required fields');
|
!certificationId ||
|
||||||
|
!titleEn ||
|
||||||
|
!titleAm ||
|
||||||
|
!date ||
|
||||||
|
!type ||
|
||||||
|
!form ||
|
||||||
|
!venue ||
|
||||||
|
!adminMethod ||
|
||||||
|
!evalMethod
|
||||||
|
) {
|
||||||
|
notify.error("Please fill all required fields");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
onSubmit({
|
onSubmit(
|
||||||
certificationId, titleEn, titleAm, directionEn, directionAm,
|
{
|
||||||
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
|
certificationId,
|
||||||
}, !!editing);
|
titleEn,
|
||||||
|
titleAm,
|
||||||
|
directionEn,
|
||||||
|
directionAm,
|
||||||
|
date,
|
||||||
|
days,
|
||||||
|
hours,
|
||||||
|
minutes,
|
||||||
|
type,
|
||||||
|
form,
|
||||||
|
venue,
|
||||||
|
adminMethod,
|
||||||
|
evalMethod,
|
||||||
|
selMethod,
|
||||||
|
cuttingPoint,
|
||||||
|
status,
|
||||||
|
},
|
||||||
|
!!editing,
|
||||||
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||||
<Tabs.List mb="md">
|
<Tabs.List mb="md">
|
||||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
|
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||||
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
|
{t("exam.form.basicInfo")}
|
||||||
</Tabs.List>
|
</Tabs.Tab>
|
||||||
|
<Tabs.Tab
|
||||||
|
value="settings"
|
||||||
|
leftSection={<IconClipboardList size={15} />}
|
||||||
|
>
|
||||||
|
{t("exam.form.settings")}
|
||||||
|
</Tabs.Tab>
|
||||||
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="basic">
|
<Tabs.Panel value="basic">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
<Select
|
||||||
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
label={t("exam.form.certification")}
|
||||||
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
placeholder={t("exam.form.selectCertification")}
|
||||||
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
data={certOptions}
|
||||||
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
value={certificationId}
|
||||||
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
|
onChange={setCertificationId}
|
||||||
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
|
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>
|
<Text fz="sm" fw={500}>
|
||||||
<Group gap="sm" grow>
|
{t("exam.form.timeAllowed")}
|
||||||
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
</Text>
|
||||||
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
<Group gap="sm" grow>
|
||||||
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
<NumberInput
|
||||||
</Group>
|
label={t("exam.form.days")}
|
||||||
</Stack>
|
value={days}
|
||||||
</Tabs.Panel>
|
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">
|
<Tabs.Panel value="settings">
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<SimpleGrid cols={2} spacing="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
|
||||||
<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 />
|
label={t("exam.columns.type")}
|
||||||
<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 />
|
placeholder="Written or Oral"
|
||||||
<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 />
|
data={[
|
||||||
<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" />
|
{ value: "WRITTEN", label: t("exam.form.written") },
|
||||||
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
|
{ value: "ORAL", label: t("exam.form.oral") },
|
||||||
</SimpleGrid>
|
]}
|
||||||
{editing && (
|
value={type}
|
||||||
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
|
onChange={setType}
|
||||||
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
|
size="sm"
|
||||||
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
|
required
|
||||||
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
|
/>
|
||||||
]} value={status} onChange={setStatus} size="sm" />
|
<Select
|
||||||
)}
|
label={t("exam.columns.form")}
|
||||||
</Stack>
|
placeholder="Essay or Choice"
|
||||||
</Tabs.Panel>
|
data={[
|
||||||
</Tabs>
|
{ 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">
|
<ModalFooter mt="md">
|
||||||
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
|
<Button variant="default" onClick={onCancel} size="sm">
|
||||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
|
{t("exam.cancel")}
|
||||||
</Group>
|
</Button>
|
||||||
|
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||||
|
{editing ? t("exam.update") : t("exam.create")}
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ExamPage() {
|
export function ExamPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { t, i18n } = useTranslation();
|
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: 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 [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||||
const [deleteExam] = useDeleteExamMutation();
|
const [deleteExam] = useDeleteExamMutation();
|
||||||
@@ -162,26 +363,40 @@ export function ExamPage() {
|
|||||||
const [editing, setEditing] = useState<Exam | null>(null);
|
const [editing, setEditing] = useState<Exam | null>(null);
|
||||||
const [showForm, setShowForm] = useState(false);
|
const [showForm, setShowForm] = useState(false);
|
||||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
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 certOptions = certifications
|
||||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
.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 handleSubmit = async (values: any, isEdit: boolean) => {
|
||||||
const payload: any = {
|
const payload: any = {
|
||||||
certificationId: values.certificationId,
|
certificationId: values.certificationId,
|
||||||
title: { en: values.titleEn, am: values.titleAm },
|
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,
|
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,
|
type: values.type,
|
||||||
form: values.form,
|
form: values.form,
|
||||||
venue: values.venue,
|
venue: values.venue,
|
||||||
administrationMethod: values.adminMethod,
|
administrationMethod: values.adminMethod,
|
||||||
evaluationMethod: values.evalMethod,
|
evaluationMethod: values.evalMethod,
|
||||||
selectionMethod: values.selMethod || 'MANUAL',
|
selectionMethod: values.selMethod || "MANUAL",
|
||||||
cuttingPoint: values.cuttingPoint,
|
cuttingPoint: values.cuttingPoint,
|
||||||
};
|
};
|
||||||
if (isEdit) payload.status = values.status;
|
if (isEdit) payload.status = values.status;
|
||||||
@@ -189,14 +404,14 @@ export function ExamPage() {
|
|||||||
try {
|
try {
|
||||||
if (isEdit && editing) {
|
if (isEdit && editing) {
|
||||||
await updateExam({ id: editing.id, ...payload }).unwrap();
|
await updateExam({ id: editing.id, ...payload }).unwrap();
|
||||||
notify.success(t('exam.updated'));
|
notify.success(t("exam.updated"));
|
||||||
} else {
|
} else {
|
||||||
await createExam(payload).unwrap();
|
await createExam(payload).unwrap();
|
||||||
notify.success(t('exam.created'));
|
notify.success(t("exam.created"));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('exam.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -204,27 +419,143 @@ export function ExamPage() {
|
|||||||
if (!deleteTarget) return;
|
if (!deleteTarget) return;
|
||||||
try {
|
try {
|
||||||
await deleteExam(deleteTarget.id).unwrap();
|
await deleteExam(deleteTarget.id).unwrap();
|
||||||
notify.success(t('exam.deleted'));
|
notify.success(t("exam.deleted"));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('exam.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
if (isError)
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<Group justify="space-between" align="flex-end">
|
||||||
<div>
|
<div>
|
||||||
<Title order={2}>{t('exam.title')}</Title>
|
<Title order={2}>{t("exam.title")}</Title>
|
||||||
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
|
<Text fz="sm" c="dimmed">
|
||||||
|
{t("exam.subtitle")}
|
||||||
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{!showForm && (
|
{!showForm && (
|
||||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
<Button
|
||||||
{t('exam.add')}
|
variant="light"
|
||||||
|
leftSection={<IconPlus size={16} />}
|
||||||
|
onClick={() => setShowForm(true)}
|
||||||
|
size="sm"
|
||||||
|
>
|
||||||
|
{t("exam.add")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -239,70 +570,42 @@ export function ExamPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('exam.columns.title')}</Table.Th>
|
tableName={t("exam.title")}
|
||||||
<Table.Th>{t('exam.columns.certification')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('exam.columns.date')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t('exam.columns.type')}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t('exam.columns.form')}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th>{t('exam.columns.venue')}</Table.Th>
|
onPageSizeChange={setPageSize}
|
||||||
<Table.Th>{t('exam.columns.questions')}</Table.Th>
|
refresh={refetch}
|
||||||
<Table.Th>{t('exam.columns.status')}</Table.Th>
|
isLoading={isFetching}
|
||||||
<Table.Th />
|
emptyText={t("exam.noItems")}
|
||||||
</Table.Tr>
|
/>
|
||||||
</Table.Thead>
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
{/* Delete confirmation */}
|
{/* Delete confirmation */}
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
|
<Modal
|
||||||
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
|
opened={deleteOpened}
|
||||||
<Group justify="flex-end">
|
onClose={closeDelete}
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
|
title={t("exam.confirmDelete")}
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
|
size="sm"
|
||||||
</Group>
|
>
|
||||||
|
<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>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,13 +1,19 @@
|
|||||||
import type { LocalePair } from '../../certification/types/certification';
|
import type { LocalePair } from "../../certification/types/certification";
|
||||||
import type { EstimatedTime } from '../../question/types/question';
|
import type { EstimatedTime } from "../../question/types/question";
|
||||||
import type { QuestionForm } from '../../question/types/question';
|
import type { QuestionForm } from "../../question/types/question";
|
||||||
export type { QuestionForm };
|
export type { QuestionForm };
|
||||||
|
|
||||||
export type ExamType = 'WRITTEN' | 'ORAL';
|
export type ExamType = "WRITTEN" | "ORAL";
|
||||||
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
|
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||||
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
|
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||||
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
|
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||||
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
|
export type ExamStatus =
|
||||||
|
| "PENDING"
|
||||||
|
| "ACTIVE"
|
||||||
|
| "COMPLETED"
|
||||||
|
| "CANCELLED"
|
||||||
|
| "POSTPONED"
|
||||||
|
| "PUBLISHED";
|
||||||
|
|
||||||
export interface QuestionBrief {
|
export interface QuestionBrief {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
|
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
|
||||||
import { IconTrash } from '@tabler/icons-react';
|
import { IconTrash } from '@tabler/icons-react';
|
||||||
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
|
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> = {
|
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||||
DRAFT: 'gray',
|
DRAFT: 'gray',
|
||||||
@@ -12,13 +13,15 @@ const STATUS_COLORS: Record<Item['status'], string> = {
|
|||||||
export function ItemTable() {
|
export function ItemTable() {
|
||||||
const { data, isLoading } = useGetItemsQuery({});
|
const { data, isLoading } = useGetItemsQuery({});
|
||||||
const [deleteItem] = useDeleteItemMutation();
|
const [deleteItem] = useDeleteItemMutation();
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
const handleDelete = async (id: string) => {
|
const handleDelete = async (id: string) => {
|
||||||
try {
|
try {
|
||||||
await deleteItem(id).unwrap();
|
await deleteItem(id).unwrap();
|
||||||
notify.success('Item deleted');
|
notify.success('Item deleted');
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error('Failed to delete item');
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -42,7 +45,7 @@ export function ItemTable() {
|
|||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
|
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{new Date(item.createdAt).toLocaleDateString()}</Table.Td>
|
<Table.Td>{showDate(item.createdAt)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
color="red"
|
color="red"
|
||||||
|
|||||||
@@ -19,9 +19,10 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
|
import { IconSearch, IconShieldCog } from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
localized,
|
useLocalized,
|
||||||
useGetLicensesQuery,
|
useGetLicensesQuery,
|
||||||
useReinstateLicenseMutation,
|
useReinstateLicenseMutation,
|
||||||
useRevokeLicenseMutation,
|
useRevokeLicenseMutation,
|
||||||
@@ -166,6 +167,8 @@ export function LicenseRegisterPage() {
|
|||||||
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
@@ -227,12 +230,12 @@ export function LicenseRegisterPage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{license.issueDate?.slice(0, 10)}
|
{showDate(license.issueDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{license.expiryDate?.slice(0, 10)}
|
{showDate(license.expiryDate)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
type ApplicationDetail,
|
type ApplicationDetail,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
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.
|
* notifications sent to the applicant are not among them.
|
||||||
*/
|
*/
|
||||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
const entries = useMemo<ActivityEntry[]>(() => {
|
const entries = useMemo<ActivityEntry[]>(() => {
|
||||||
const merged: ActivityEntry[] = [];
|
const merged: ActivityEntry[] = [];
|
||||||
@@ -69,9 +71,11 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
|||||||
? t(`review.events.${history.event}`, {
|
? t(`review.events.${history.event}`, {
|
||||||
defaultValue: 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,
|
detail: history.remark ?? undefined,
|
||||||
color: STATUS_COLORS[history.toStatus],
|
color: STATUS_COLORS[history.toStatus],
|
||||||
});
|
});
|
||||||
@@ -159,12 +163,9 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
|||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
·
|
·
|
||||||
</Text>
|
</Text>
|
||||||
<Tooltip
|
<Tooltip label={showDate(entry.at)} withArrow>
|
||||||
label={new Date(entry.at).toLocaleString(i18n.language)}
|
|
||||||
withArrow
|
|
||||||
>
|
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{new Date(entry.at).toLocaleDateString(i18n.language)}
|
{showDate(entry.at.slice(0, 10))}
|
||||||
</Text>
|
</Text>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</Group>
|
</Group>
|
||||||
|
|||||||
@@ -80,7 +80,7 @@ export function DecisionBar({
|
|||||||
{/* Left: where the application stands, and who has it. */}
|
{/* Left: where the application stands, and who has it. */}
|
||||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||||
{STATUS_LABELS[status]}
|
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
|
|
||||||
{assigneeName && (
|
{assigneeName && (
|
||||||
|
|||||||
@@ -3,7 +3,6 @@ import {
|
|||||||
Alert,
|
Alert,
|
||||||
Button,
|
Button,
|
||||||
Checkbox,
|
Checkbox,
|
||||||
Group,
|
|
||||||
Modal,
|
Modal,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
@@ -13,6 +12,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { ModalFooter } from '@ema-platform/ui';
|
||||||
import type { ResolvedAction } from '../config/actions';
|
import type { ResolvedAction } from '../config/actions';
|
||||||
|
|
||||||
/** Reason codes offered per action. Free text is always available too. */
|
/** Reason codes offered per action. Free text is always available too. */
|
||||||
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t('common.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
|
|||||||
>
|
>
|
||||||
{t(action.labelKey)}
|
{t(action.labelKey)}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
|
|||||||
import {
|
import {
|
||||||
useClearDocumentReviewMutation,
|
useClearDocumentReviewMutation,
|
||||||
useGetDocumentReviewsQuery,
|
useGetDocumentReviewsQuery,
|
||||||
|
useLocalized,
|
||||||
useReviewDocumentMutation,
|
useReviewDocumentMutation,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
type DocumentRequirement,
|
type DocumentRequirement,
|
||||||
@@ -62,6 +63,7 @@ export function DocumentsTab({
|
|||||||
onFlagRemark,
|
onFlagRemark,
|
||||||
}: DocumentsTabProps) {
|
}: DocumentsTabProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||||
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
||||||
|
|
||||||
@@ -114,6 +116,7 @@ export function DocumentsTab({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
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 mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
|
||||||
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
||||||
const completeness = mandatory.length
|
const completeness = mandatory.length
|
||||||
@@ -148,7 +151,7 @@ export function DocumentsTab({
|
|||||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
{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>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -162,49 +165,56 @@ export function DocumentsTab({
|
|||||||
return (
|
return (
|
||||||
<Paper withBorder p="md" key={attachment.id}>
|
<Paper withBorder p="md" key={attachment.id}>
|
||||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
<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} />
|
<IconFileText size={20} stroke={1.6} />
|
||||||
<div style={{ minWidth: 0 }}>
|
<div style={{ minWidth: 0, flex: 1 }}>
|
||||||
<Text size="sm" fw={500}>
|
{/* Badges sit beside the name, not in the outer nowrap row —
|
||||||
{attachment.documentKey}
|
that row also has to fit six action buttons, so a long
|
||||||
</Text>
|
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>
|
<Text size="xs" c="dimmed" truncate>
|
||||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</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>
|
||||||
|
|
||||||
<Group gap="xs" wrap="nowrap">
|
<Group gap="xs" wrap="nowrap">
|
||||||
@@ -387,7 +397,11 @@ export function DocumentsTab({
|
|||||||
onClose={() => setPreview(null)}
|
onClose={() => setPreview(null)}
|
||||||
position="right"
|
position="right"
|
||||||
size="xl"
|
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
|
// Focus is trapped and returned so keyboard users are not dropped at
|
||||||
// the top of the page when the drawer closes.
|
// the top of the page when the drawer closes.
|
||||||
trapFocus
|
trapFocus
|
||||||
@@ -397,13 +411,13 @@ export function DocumentsTab({
|
|||||||
isPdf ? (
|
isPdf ? (
|
||||||
<iframe
|
<iframe
|
||||||
src={previewFile.url}
|
src={previewFile.url}
|
||||||
title={preview?.documentKey ?? 'document'}
|
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||||
/>
|
/>
|
||||||
) : isImage ? (
|
) : isImage ? (
|
||||||
<img
|
<img
|
||||||
src={previewFile.url}
|
src={previewFile.url}
|
||||||
alt={preview?.documentKey ?? 'document'}
|
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||||
style={{ maxWidth: '100%' }}
|
style={{ maxWidth: '100%' }}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
import {
|
import {
|
||||||
IconAnchor,
|
IconAnchor,
|
||||||
|
IconArrowsExchange,
|
||||||
IconFileDescription,
|
IconFileDescription,
|
||||||
|
IconId,
|
||||||
|
IconRubberStamp,
|
||||||
IconShip,
|
IconShip,
|
||||||
IconTruck,
|
IconTruck,
|
||||||
IconUsers,
|
IconUsers,
|
||||||
@@ -77,6 +80,36 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
|||||||
icon: IconAnchor,
|
icon: IconAnchor,
|
||||||
detailSections: DEFAULT_SECTIONS,
|
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. */
|
/** 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
|
* pass/fail line means the rule, the figure it was checked against, and the
|
||||||
* outcome are all on screen.
|
* 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(
|
export function evaluateEligibility(
|
||||||
application: LicenseApplication,
|
application: LicenseApplication,
|
||||||
licenseType: LicenseType | undefined,
|
licenseType: LicenseType | undefined,
|
||||||
locale: string,
|
locale: string,
|
||||||
|
t: Translate,
|
||||||
): EligibilityRule[] {
|
): EligibilityRule[] {
|
||||||
const rules: EligibilityRule[] = [];
|
const rules: EligibilityRule[] = [];
|
||||||
|
|
||||||
@@ -139,11 +177,18 @@ export function evaluateEligibility(
|
|||||||
|
|
||||||
rules.push({
|
rules.push({
|
||||||
id: 'capital-threshold',
|
id: 'capital-threshold',
|
||||||
label: `Paid-up capital ≥ ${format(threshold)}`,
|
label: t('review.eligibilityRule.capitalThreshold', {
|
||||||
|
amount: format(threshold),
|
||||||
|
defaultValue: 'Paid-up capital ≥ {{amount}}',
|
||||||
|
}),
|
||||||
actual:
|
actual:
|
||||||
effective === undefined
|
effective === undefined
|
||||||
? 'Not recorded'
|
? t('review.eligibilityRule.notRecorded', 'Not recorded')
|
||||||
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
|
: `${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
|
// An unverified declaration is not evidence, so it reads as unknown
|
||||||
// rather than as a pass the officer never actually made.
|
// rather than as a pass the officer never actually made.
|
||||||
status:
|
status:
|
||||||
@@ -167,8 +212,10 @@ export function evaluateEligibility(
|
|||||||
].includes(application.status);
|
].includes(application.status);
|
||||||
rules.push({
|
rules.push({
|
||||||
id: 'inspection',
|
id: 'inspection',
|
||||||
label: 'Physical inspection completed',
|
label: t('review.eligibilityRule.inspectionCompleted', 'Physical inspection completed'),
|
||||||
actual: inspected ? 'Recorded' : 'Not yet recorded',
|
actual: inspected
|
||||||
|
? t('review.eligibilityRule.recorded', 'Recorded')
|
||||||
|
: t('review.eligibilityRule.notYetRecorded', 'Not yet recorded'),
|
||||||
status: inspected ? 'pass' : 'unknown',
|
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';
|
import { computeSla } from './sla';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -21,21 +22,19 @@ const COLUMNS: Array<{
|
|||||||
{ header: 'Company', value: (a) => a.companyName },
|
{ header: 'Company', value: (a) => a.companyName },
|
||||||
{ header: 'Trade name', value: (a) => a.tradeName },
|
{ header: 'Trade name', value: (a) => a.tradeName },
|
||||||
{ header: 'TIN', value: (a) => a.tinNumber },
|
{ 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: 'Status', value: (a) => STATUS_LABELS[a.status] },
|
||||||
{ header: 'Kind', value: (a) => a.kind },
|
{ header: 'Kind', value: (a) => a.kind },
|
||||||
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
||||||
{
|
{
|
||||||
header: 'Submitted',
|
header: 'Submitted',
|
||||||
value: (a, locale) =>
|
value: (a, locale) => (a.submittedAt ? dateDisplayer(a.submittedAt, locale) : ''),
|
||||||
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
|
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
header: 'Decided',
|
header: 'Decided',
|
||||||
value: (a, locale) =>
|
value: (a, locale) => (a.decidedAt ? dateDisplayer(a.decidedAt, locale) : ''),
|
||||||
a.decidedAt ? new Date(a.decidedAt).toLocaleString(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: 'Adjustment rounds', value: (a) => a.adjustmentRound },
|
||||||
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
|
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
|
||||||
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
|
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useMemo, useState } from "react";
|
||||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -9,7 +8,6 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
MultiSelect,
|
MultiSelect,
|
||||||
Pagination,
|
|
||||||
Paper,
|
Paper,
|
||||||
SegmentedControl,
|
SegmentedControl,
|
||||||
Select,
|
Select,
|
||||||
@@ -17,29 +15,30 @@ import {
|
|||||||
Stack,
|
Stack,
|
||||||
Kbd,
|
Kbd,
|
||||||
Modal,
|
Modal,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
Tooltip,
|
||||||
} from '@mantine/core';
|
} from "@mantine/core";
|
||||||
import { useDebouncedValue } from '@mantine/hooks';
|
import { useDebouncedValue } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconAlertCircle,
|
IconAlertCircle,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconRefresh,
|
|
||||||
IconSearch,
|
IconSearch,
|
||||||
IconSortAscending,
|
IconSortAscending,
|
||||||
IconSortDescending,
|
IconSortDescending,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from "@mantine/notifications";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from "react-i18next";
|
||||||
import {
|
import {
|
||||||
|
APPLICANT_NAME_TYPE_KEYS,
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
|
applicantOrCompanyName,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
localized,
|
||||||
useClaimApplicationMutation,
|
useClaimApplicationMutation,
|
||||||
useGetAllApplicationsQuery,
|
useGetAllApplicationsQuery,
|
||||||
useGetAssignedToMeQuery,
|
useGetAssignedToMeQuery,
|
||||||
@@ -50,9 +49,16 @@ import {
|
|||||||
type LicenseApplication,
|
type LicenseApplication,
|
||||||
type LicenseStatus,
|
type LicenseStatus,
|
||||||
type QueueFilter,
|
type QueueFilter,
|
||||||
} from '@ema-platform/api';
|
} from "@ema-platform/api";
|
||||||
import { EmptyState, ErrorState } from '@ema-platform/ui';
|
import {
|
||||||
import { computeSla } from '../sla';
|
AdvancedTable,
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
AmharicDatePicker,
|
||||||
|
type AdvancedColumn,
|
||||||
|
} from "@ema-platform/ui";
|
||||||
|
import { dateDisplayer } from "@ema-platform/shared";
|
||||||
|
import { computeSla } from "../sla";
|
||||||
import {
|
import {
|
||||||
DEFAULT_VIEW,
|
DEFAULT_VIEW,
|
||||||
SAVED_VIEWS,
|
SAVED_VIEWS,
|
||||||
@@ -61,30 +67,30 @@ import {
|
|||||||
searchParamsFromFilter,
|
searchParamsFromFilter,
|
||||||
writeLastView,
|
writeLastView,
|
||||||
type SavedViewId,
|
type SavedViewId,
|
||||||
} from '../queue-views';
|
} from "../queue-views";
|
||||||
import { exportApplicationsCsv } from '../export';
|
import { exportApplicationsCsv } from "../export";
|
||||||
import { setDensity } from '../../../store/preferences.slice';
|
import { setDensity } from "../../../store/preferences.slice";
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
|
||||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../useQueueKeyboard';
|
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../useQueueKeyboard";
|
||||||
|
|
||||||
const PAGE_SIZE = 25;
|
const PAGE_SIZE = 10;
|
||||||
const SEARCH_DEBOUNCE_MS = 300;
|
const SEARCH_DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
const ALL_STATUSES: LicenseStatus[] = [
|
const ALL_STATUSES: LicenseStatus[] = [
|
||||||
'SUBMITTED',
|
"SUBMITTED",
|
||||||
'UNDER_REVIEW',
|
"UNDER_REVIEW",
|
||||||
'UNDER_EVALUATION',
|
"UNDER_EVALUATION",
|
||||||
'RESUBMIT_REQUIRED',
|
"RESUBMIT_REQUIRED",
|
||||||
'INSPECTION_PENDING',
|
"INSPECTION_PENDING",
|
||||||
'INSPECTION_COMPLETED',
|
"INSPECTION_COMPLETED",
|
||||||
'ON_HOLD',
|
"ON_HOLD",
|
||||||
'APPROVED',
|
"APPROVED",
|
||||||
'PAYMENT_PENDING',
|
"PAYMENT_PENDING",
|
||||||
'PAID',
|
"PAID",
|
||||||
'PAYMENT_CONFIRMED',
|
"PAYMENT_CONFIRMED",
|
||||||
'CERTIFICATE_ISSUED',
|
"CERTIFICATE_ISSUED",
|
||||||
'COMPLETED',
|
"COMPLETED",
|
||||||
'REJECTED',
|
"REJECTED",
|
||||||
];
|
];
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -104,11 +110,12 @@ export function LicenseQueuePage() {
|
|||||||
const density = useAppSelector((state) => state.preferences.density);
|
const density = useAppSelector((state) => state.preferences.density);
|
||||||
|
|
||||||
const [view, setView] = useState<SavedViewId>(
|
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 [selected, setSelected] = useState<string[]>([]);
|
||||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
const [searchInput, setSearchInput] = useState(searchParams.get("q") ?? "");
|
||||||
const [cursor, setCursor] = useState(0);
|
const [cursor, setCursor] = useState(0);
|
||||||
const [helpOpen, setHelpOpen] = useState(false);
|
const [helpOpen, setHelpOpen] = useState(false);
|
||||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||||
@@ -134,22 +141,42 @@ export function LicenseQueuePage() {
|
|||||||
...urlFilter,
|
...urlFilter,
|
||||||
search: debouncedSearch || undefined,
|
search: debouncedSearch || undefined,
|
||||||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||||||
take: PAGE_SIZE,
|
// No explicit sort in the URL or view → newest submissions first, so
|
||||||
skip: (page - 1) * PAGE_SIZE,
|
// 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
|
// One query per source; the two inactive ones are skipped, so switching
|
||||||
// views costs a single request rather than keeping three in flight.
|
// views costs a single request rather than keeping three in flight.
|
||||||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
const queueQuery = useGetQueueQuery(filter, {
|
||||||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
skip: activeView.source !== "queue",
|
||||||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
});
|
||||||
|
const mineQuery = useGetAssignedToMeQuery(filter, {
|
||||||
|
skip: activeView.source !== "mine",
|
||||||
|
});
|
||||||
|
const allQuery = useGetAllApplicationsQuery(filter, {
|
||||||
|
skip: activeView.source !== "all",
|
||||||
|
});
|
||||||
const active =
|
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 [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.
|
* Exports every row the filter matches, not just the page on screen.
|
||||||
@@ -158,24 +185,28 @@ export function LicenseQueuePage() {
|
|||||||
*/
|
*/
|
||||||
async function handleExport() {
|
async function handleExport() {
|
||||||
try {
|
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);
|
exportApplicationsCsv(result.items, i18n.language);
|
||||||
if (result.truncated) {
|
if (result.truncated) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'yellow',
|
color: "yellow",
|
||||||
title: t('queue.exportTruncated', 'Export truncated'),
|
title: t("queue.exportTruncated", "Export truncated"),
|
||||||
message: t('queue.exportTruncatedBody', {
|
message: t("queue.exportTruncatedBody", {
|
||||||
exported: result.items.length,
|
exported: result.items.length,
|
||||||
total: result.total,
|
total: result.total,
|
||||||
defaultValue:
|
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) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: "red",
|
||||||
title: t('queue.exportFailed', 'Export failed'),
|
title: t("queue.exportFailed", "Export failed"),
|
||||||
message: extractErrorMessage(err),
|
message: extractErrorMessage(err),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -183,7 +214,6 @@ export function LicenseQueuePage() {
|
|||||||
|
|
||||||
const items = active.data?.items ?? [];
|
const items = active.data?.items ?? [];
|
||||||
const total = active.data?.total ?? 0;
|
const total = active.data?.total ?? 0;
|
||||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
|
||||||
|
|
||||||
const updateUrl = useCallback(
|
const updateUrl = useCallback(
|
||||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||||
@@ -208,9 +238,11 @@ export function LicenseQueuePage() {
|
|||||||
updateUrl(next, view, 1);
|
updateUrl(next, view, 1);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||||
const dir =
|
const dir =
|
||||||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||||||
|
? "DESC"
|
||||||
|
: "ASC";
|
||||||
setFacet({ sortBy: field, sortDir: dir });
|
setFacet({ sortBy: field, sortDir: dir });
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -218,20 +250,23 @@ export function LicenseQueuePage() {
|
|||||||
try {
|
try {
|
||||||
await claim(id).unwrap();
|
await claim(id).unwrap();
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'teal',
|
color: "teal",
|
||||||
title: t('queue.claimed', 'Claimed'),
|
title: t("queue.claimed", "Claimed"),
|
||||||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
message: t(
|
||||||
|
"queue.claimedBody",
|
||||||
|
"The application is now assigned to you.",
|
||||||
|
),
|
||||||
});
|
});
|
||||||
changeView('mine');
|
changeView("mine");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// A 409 means another officer got there first — refresh so the queue
|
// A 409 means another officer got there first — refresh so the queue
|
||||||
// stops showing work that is no longer available.
|
// stops showing work that is no longer available.
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'red',
|
color: "red",
|
||||||
title: t('queue.claimFailed', 'Could not claim'),
|
title: t("queue.claimFailed", "Could not claim"),
|
||||||
message: extractErrorMessage(
|
message: extractErrorMessage(
|
||||||
err,
|
err,
|
||||||
t('queue.claimRace', 'Another officer already claimed it.'),
|
t("queue.claimRace", "Another officer already claimed it."),
|
||||||
),
|
),
|
||||||
});
|
});
|
||||||
active.refetch();
|
active.refetch();
|
||||||
@@ -242,19 +277,22 @@ export function LicenseQueuePage() {
|
|||||||
const results = await Promise.allSettled(
|
const results = await Promise.allSettled(
|
||||||
selected.map((id) => claim(id).unwrap()),
|
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;
|
const lost = results.length - claimed;
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: lost ? 'yellow' : 'teal',
|
color: lost ? "yellow" : "teal",
|
||||||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
title: t("queue.bulkClaimed", {
|
||||||
|
count: claimed,
|
||||||
|
defaultValue: "{{count}} claimed",
|
||||||
|
}),
|
||||||
// Partial success is the normal case in a shared queue, so it is
|
// Partial success is the normal case in a shared queue, so it is
|
||||||
// reported rather than swallowed or treated as total failure.
|
// reported rather than swallowed or treated as total failure.
|
||||||
message: lost
|
message: lost
|
||||||
? t('queue.bulkClaimPartial', {
|
? t("queue.bulkClaimPartial", {
|
||||||
count: lost,
|
count: lost,
|
||||||
defaultValue: '{{count}} were already taken by another officer.',
|
defaultValue: "{{count}} were already taken by another officer.",
|
||||||
})
|
})
|
||||||
: '',
|
: "",
|
||||||
});
|
});
|
||||||
setSelected([]);
|
setSelected([]);
|
||||||
active.refetch();
|
active.refetch();
|
||||||
@@ -263,13 +301,15 @@ export function LicenseQueuePage() {
|
|||||||
const cursorRow = items[cursor];
|
const cursorRow = items[cursor];
|
||||||
useQueueKeyboard({
|
useQueueKeyboard({
|
||||||
enabled: !helpOpen,
|
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)),
|
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||||
onClaim: () => {
|
onClaim: () => {
|
||||||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||||||
// rather than an error the officer has to read.
|
// 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([]),
|
onEscape: () => setSelected([]),
|
||||||
onHelp: () => setHelpOpen(true),
|
onHelp: () => setHelpOpen(true),
|
||||||
@@ -277,21 +317,187 @@ export function LicenseQueuePage() {
|
|||||||
|
|
||||||
const allSelected = items.length > 0 && selected.length === items.length;
|
const allSelected = items.length > 0 && selected.length === items.length;
|
||||||
const sortIcon =
|
const sortIcon =
|
||||||
urlFilter.sortDir === 'DESC' ? <IconSortDescending size={13} /> : <IconSortAscending size={13} />;
|
urlFilter.sortDir === "DESC" ? (
|
||||||
|
<IconSortDescending size={13} />
|
||||||
|
) : (
|
||||||
|
<IconSortAscending size={13} />
|
||||||
|
);
|
||||||
|
|
||||||
const hasFacets = Boolean(
|
const hasFacets = Boolean(
|
||||||
urlFilter.status?.length ||
|
urlFilter.status?.length ||
|
||||||
urlFilter.licenseTypeId ||
|
urlFilter.licenseTypeId ||
|
||||||
urlFilter.assignee ||
|
urlFilter.assignee ||
|
||||||
urlFilter.submittedFrom ||
|
urlFilter.submittedFrom ||
|
||||||
debouncedSearch,
|
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 (
|
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">
|
<Group justify="space-between" mb="md">
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
|
||||||
{typeCode && (
|
{typeCode && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||||
@@ -299,18 +505,18 @@ export function LicenseQueuePage() {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
|
||||||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
|
||||||
<IconRefresh size={18} />
|
|
||||||
</ActionIcon>
|
|
||||||
</Tooltip>
|
|
||||||
<SegmentedControl
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={density}
|
value={density}
|
||||||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
onChange={(v) =>
|
||||||
|
dispatch(setDensity(v as "comfortable" | "compact"))
|
||||||
|
}
|
||||||
data={[
|
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
|
<Button
|
||||||
@@ -320,13 +526,17 @@ export function LicenseQueuePage() {
|
|||||||
loading={exporting}
|
loading={exporting}
|
||||||
disabled={total === 0}
|
disabled={total === 0}
|
||||||
>
|
>
|
||||||
{t('queue.export', 'Export CSV')}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
{/* Saved views, counted. */}
|
{/* 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>
|
<Tabs.List>
|
||||||
{SAVED_VIEWS.map((savedView) => (
|
{SAVED_VIEWS.map((savedView) => (
|
||||||
<Tabs.Tab
|
<Tabs.Tab
|
||||||
@@ -334,7 +544,7 @@ export function LicenseQueuePage() {
|
|||||||
value={savedView.id}
|
value={savedView.id}
|
||||||
rightSection={
|
rightSection={
|
||||||
counts?.[savedView.countKey] ? (
|
counts?.[savedView.countKey] ? (
|
||||||
<Badge size="xs" variant="light" circle>
|
<Badge size="xs" variant="light">
|
||||||
{counts[savedView.countKey]}
|
{counts[savedView.countKey]}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : undefined
|
) : undefined
|
||||||
@@ -350,17 +560,20 @@ export function LicenseQueuePage() {
|
|||||||
<Paper withBorder p="sm" mb="sm">
|
<Paper withBorder p="sm" mb="sm">
|
||||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||||
<TextInput
|
<TextInput
|
||||||
label={t('queue.search', 'Search')}
|
label={t("queue.search", "Search")}
|
||||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
placeholder={t("queue.searchPlaceholder", "Company, TIN or number")}
|
||||||
leftSection={<IconSearch size={14} />}
|
leftSection={<IconSearch size={14} />}
|
||||||
value={searchInput}
|
value={searchInput}
|
||||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||||
w={240}
|
w={240}
|
||||||
/>
|
/>
|
||||||
<MultiSelect
|
<MultiSelect
|
||||||
label={t('queue.status', 'Status')}
|
label={t("queue.status", "Status")}
|
||||||
placeholder={t('queue.anyStatus', 'Any')}
|
placeholder={t("queue.anyStatus", "Any")}
|
||||||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
data={ALL_STATUSES.map((s) => ({
|
||||||
|
value: s,
|
||||||
|
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||||||
|
}))}
|
||||||
value={urlFilter.status ?? []}
|
value={urlFilter.status ?? []}
|
||||||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||||||
clearable
|
clearable
|
||||||
@@ -368,11 +581,11 @@ export function LicenseQueuePage() {
|
|||||||
/>
|
/>
|
||||||
{!typeCode && (
|
{!typeCode && (
|
||||||
<Select
|
<Select
|
||||||
label={t('queue.type', 'Licence type')}
|
label={t("queue.type", "Licence type")}
|
||||||
placeholder={t('queue.anyType', 'Any')}
|
placeholder={t("queue.anyType", "Any")}
|
||||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||||
value: type.id,
|
value: type.id,
|
||||||
label: type.name.en ?? type.key,
|
label: localized(type.name, i18n.language) || type.key,
|
||||||
}))}
|
}))}
|
||||||
value={urlFilter.licenseTypeId ?? null}
|
value={urlFilter.licenseTypeId ?? null}
|
||||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||||
@@ -380,28 +593,30 @@ export function LicenseQueuePage() {
|
|||||||
w={220}
|
w={220}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
label={t("queue.submittedFrom", "Submitted from")}
|
||||||
label={t('queue.submittedFrom', 'Submitted from')}
|
value={urlFilter.submittedFrom ?? ""}
|
||||||
value={urlFilter.submittedFrom ?? ''}
|
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
dateFormat="date"
|
||||||
|
w={170}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
label={t("queue.submittedTo", "Submitted to")}
|
||||||
label={t('queue.submittedTo', 'Submitted to')}
|
value={urlFilter.submittedTo ?? ""}
|
||||||
value={urlFilter.submittedTo ?? ''}
|
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
dateFormat="date"
|
||||||
|
w={170}
|
||||||
/>
|
/>
|
||||||
{hasFacets && (
|
{hasFacets && (
|
||||||
<Button
|
<Button
|
||||||
variant="subtle"
|
variant="subtle"
|
||||||
leftSection={<IconX size={14} />}
|
leftSection={<IconX size={14} />}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setSearchInput('');
|
setSearchInput("");
|
||||||
setSearchParams(new URLSearchParams(), { replace: true });
|
setSearchParams(new URLSearchParams(), { replace: true });
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{t('queue.clearFilters', 'Clear')}
|
{t("queue.clearFilters", "Clear")}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -418,7 +633,7 @@ export function LicenseQueuePage() {
|
|||||||
</Stack>
|
</Stack>
|
||||||
) : active.isError ? (
|
) : active.isError ? (
|
||||||
<ErrorState
|
<ErrorState
|
||||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
title={t("queue.errorTitle", "Could not load the queue")}
|
||||||
description={extractErrorMessage(active.error)}
|
description={extractErrorMessage(active.error)}
|
||||||
onRetry={() => active.refetch()}
|
onRetry={() => active.refetch()}
|
||||||
icon={IconAlertCircle}
|
icon={IconAlertCircle}
|
||||||
@@ -427,114 +642,73 @@ export function LicenseQueuePage() {
|
|||||||
<EmptyState
|
<EmptyState
|
||||||
title={
|
title={
|
||||||
hasFacets
|
hasFacets
|
||||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
? t(
|
||||||
: t('queue.empty', 'Nothing waiting here')
|
"queue.emptyFiltered",
|
||||||
|
"No applications match these filters",
|
||||||
|
)
|
||||||
|
: t("queue.empty", "Nothing waiting here")
|
||||||
}
|
}
|
||||||
description={
|
description={
|
||||||
hasFacets
|
hasFacets
|
||||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
? t(
|
||||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
"queue.emptyFilteredBody",
|
||||||
|
"Try widening or clearing the filters.",
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"queue.emptyBody",
|
||||||
|
"New applications will appear here as they are submitted.",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
action={
|
action={
|
||||||
hasFacets
|
hasFacets
|
||||||
? {
|
? {
|
||||||
label: t('queue.clearFilters', 'Clear'),
|
label: t("queue.clearFilters", "Clear"),
|
||||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
onClick: () =>
|
||||||
|
setSearchParams(new URLSearchParams(), { replace: true }),
|
||||||
}
|
}
|
||||||
: undefined
|
: undefined
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<Table.ScrollContainer minWidth={1100}>
|
<Group justify="flex-end" p="sm" pb={0}>
|
||||||
<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">
|
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{t('queue.showing', {
|
{t("queue.showing", {
|
||||||
from: (page - 1) * PAGE_SIZE + 1,
|
from: (page - 1) * pageSize + 1,
|
||||||
to: Math.min(page * PAGE_SIZE, total),
|
to: Math.min(page * pageSize, total),
|
||||||
total,
|
total,
|
||||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Pagination
|
|
||||||
value={page}
|
|
||||||
onChange={(next) => {
|
|
||||||
setPage(next);
|
|
||||||
updateUrl({}, view, next);
|
|
||||||
}}
|
|
||||||
total={pageCount}
|
|
||||||
size="sm"
|
|
||||||
/>
|
|
||||||
</Group>
|
</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>
|
</Card>
|
||||||
@@ -542,7 +716,7 @@ export function LicenseQueuePage() {
|
|||||||
<Modal
|
<Modal
|
||||||
opened={helpOpen}
|
opened={helpOpen}
|
||||||
onClose={() => setHelpOpen(false)}
|
onClose={() => setHelpOpen(false)}
|
||||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
title={t("shortcuts.title", "Keyboard shortcuts")}
|
||||||
size="sm"
|
size="sm"
|
||||||
>
|
>
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
@@ -562,18 +736,18 @@ export function LicenseQueuePage() {
|
|||||||
withBorder
|
withBorder
|
||||||
shadow="md"
|
shadow="md"
|
||||||
p="sm"
|
p="sm"
|
||||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
style={{ position: "sticky", bottom: 16, zIndex: 50 }}
|
||||||
>
|
>
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={500}>
|
<Text size="sm" fw={500}>
|
||||||
{t('queue.selectedCount', {
|
{t("queue.selectedCount", {
|
||||||
count: selected.length,
|
count: selected.length,
|
||||||
defaultValue: '{{count}} selected',
|
defaultValue: "{{count}} selected",
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="xs">
|
<Group gap="xs">
|
||||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||||
{t('common.cancel', 'Cancel')}
|
{t("common.cancel", "Cancel")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
@@ -585,12 +759,12 @@ export function LicenseQueuePage() {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
{t('queue.export', 'Export CSV')}
|
{t("queue.export", "Export CSV")}
|
||||||
</Button>
|
</Button>
|
||||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||||
{t('queue.bulkClaim', {
|
{t("queue.bulkClaim", {
|
||||||
count: selected.length,
|
count: selected.length,
|
||||||
defaultValue: 'Claim {{count}}',
|
defaultValue: "Claim {{count}}",
|
||||||
})}
|
})}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</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;
|
export default LicenseQueuePage;
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import {
|
|||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
useLocalized,
|
||||||
useApproveDocumentsMutation,
|
useApproveDocumentsMutation,
|
||||||
useAssignApplicationMutation,
|
useAssignApplicationMutation,
|
||||||
useCompleteReviewMutation,
|
useCompleteReviewMutation,
|
||||||
@@ -46,6 +47,7 @@ import {
|
|||||||
useEscalateApplicationMutation,
|
useEscalateApplicationMutation,
|
||||||
useFinalApproveMutation,
|
useFinalApproveMutation,
|
||||||
useGetApplicationForReviewQuery,
|
useGetApplicationForReviewQuery,
|
||||||
|
useGetAttachmentsQuery,
|
||||||
useGetInspectionsQuery,
|
useGetInspectionsQuery,
|
||||||
useGetAssignableOfficersQuery,
|
useGetAssignableOfficersQuery,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
@@ -57,7 +59,8 @@ import {
|
|||||||
useScheduleInspectionMutation,
|
useScheduleInspectionMutation,
|
||||||
type RemarkTargetType,
|
type RemarkTargetType,
|
||||||
} from '@ema-platform/api';
|
} 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 { usePermissions } from '@ema-platform/auth';
|
||||||
import { useAppSelector } from '../../../store/hooks';
|
import { useAppSelector } from '../../../store/hooks';
|
||||||
import { DecisionBar } from '../components/DecisionBar';
|
import { DecisionBar } from '../components/DecisionBar';
|
||||||
@@ -79,10 +82,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
|||||||
* that fills it in.
|
* that fills it in.
|
||||||
*/
|
*/
|
||||||
const INSPECTION_CHECKLIST_ITEMS = [
|
const INSPECTION_CHECKLIST_ITEMS = [
|
||||||
{ key: 'office_premises', label: 'Office premises' },
|
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
|
||||||
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
|
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
|
||||||
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
|
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
|
||||||
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
|
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
function buildChecklist(
|
function buildChecklist(
|
||||||
@@ -90,7 +93,9 @@ function buildChecklist(
|
|||||||
) {
|
) {
|
||||||
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
||||||
key: item.key,
|
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
|
// Untouched rows default to PASS — the segmented control shows exactly
|
||||||
// that, so what the officer saw is what gets recorded.
|
// that, so what the officer saw is what gets recorded.
|
||||||
outcome: outcomes[item.key] ?? 'PASS',
|
outcome: outcomes[item.key] ?? 'PASS',
|
||||||
@@ -108,6 +113,8 @@ function buildChecklist(
|
|||||||
*/
|
*/
|
||||||
export function LicenseReviewPage() {
|
export function LicenseReviewPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
const { id = '' } = useParams();
|
const { id = '' } = useParams();
|
||||||
const { can } = usePermissions();
|
const { can } = usePermissions();
|
||||||
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
||||||
@@ -122,6 +129,13 @@ export function LicenseReviewPage() {
|
|||||||
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
|
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
|
||||||
{ skip: !data?.application.licenseTypeId },
|
{ 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 [completeReview] = useCompleteReviewMutation();
|
||||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||||
@@ -200,7 +214,7 @@ export function LicenseReviewPage() {
|
|||||||
return {
|
return {
|
||||||
key,
|
key,
|
||||||
label: member
|
label: member
|
||||||
? `${member.roleKey} — ${member.fullName}`
|
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||||
: t('review.staffMember', 'Staff member'),
|
: t('review.staffMember', 'Staff member'),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -210,7 +224,7 @@ export function LicenseReviewPage() {
|
|||||||
return { key, label: key };
|
return { key, label: key };
|
||||||
}),
|
}),
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
[flags, data?.staff, t],
|
[flags, data?.staff, t, localized, roleNameByKey],
|
||||||
);
|
);
|
||||||
|
|
||||||
const actions = useMemo(() => {
|
const actions = useMemo(() => {
|
||||||
@@ -267,8 +281,10 @@ export function LicenseReviewPage() {
|
|||||||
const app = data.application;
|
const app = data.application;
|
||||||
const status = app.status;
|
const status = app.status;
|
||||||
const presentation = presentationFor(app.licenseType?.key);
|
const presentation = presentationFor(app.licenseType?.key);
|
||||||
const sla = computeSla(app);
|
// 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);
|
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 rawThreshold = app.licenseType?.capitalThreshold;
|
||||||
const threshold =
|
const threshold =
|
||||||
@@ -482,6 +498,12 @@ export function LicenseReviewPage() {
|
|||||||
|
|
||||||
const sections = presentation.detailSections;
|
const sections = presentation.detailSections;
|
||||||
const formSections = Object.entries(app.formData ?? {});
|
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 (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
@@ -493,7 +515,7 @@ export function LicenseReviewPage() {
|
|||||||
{app.applicationNumber}
|
{app.applicationNumber}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||||
{STATUS_LABELS[status]}
|
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
{app.adjustmentRound > 0 && (
|
{app.adjustmentRound > 0 && (
|
||||||
<Badge color="orange" variant="light" size="sm">
|
<Badge color="orange" variant="light" size="sm">
|
||||||
@@ -530,16 +552,12 @@ export function LicenseReviewPage() {
|
|||||||
{t('review.summary', 'Summary')}
|
{t('review.summary', 'Summary')}
|
||||||
</Text>
|
</Text>
|
||||||
<Stack gap={6}>
|
<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.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
|
<SummaryRow
|
||||||
label={t('review.submitted', 'Submitted')}
|
label={t('review.submitted', 'Submitted')}
|
||||||
value={
|
value={showDate(app.submittedAt)}
|
||||||
app.submittedAt
|
|
||||||
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
/>
|
/>
|
||||||
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
|
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -598,12 +616,12 @@ export function LicenseReviewPage() {
|
|||||||
key={entry.id}
|
key={entry.id}
|
||||||
title={
|
title={
|
||||||
<Text size="xs" fw={600}>
|
<Text size="xs" fw={600}>
|
||||||
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
|
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
|
||||||
</Text>
|
</Text>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
{showDate(entry.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Timeline.Item>
|
</Timeline.Item>
|
||||||
))}
|
))}
|
||||||
@@ -640,11 +658,18 @@ export function LicenseReviewPage() {
|
|||||||
|
|
||||||
<Tabs.Panel value="overview">
|
<Tabs.Panel value="overview">
|
||||||
<Stack>
|
<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">
|
<Card withBorder key={sectionKey} padding="md">
|
||||||
<Group justify="space-between" mb="xs">
|
<Group justify="space-between" mb="xs">
|
||||||
<Text fw={600} size="sm" tt="capitalize">
|
<Text fw={600} size="sm" tt="capitalize">
|
||||||
{sectionKey.replace(/([A-Z])/g, ' $1')}
|
{sectionConfig
|
||||||
|
? localized(sectionConfig.title)
|
||||||
|
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||||
</Text>
|
</Text>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
size="xs"
|
size="xs"
|
||||||
@@ -655,18 +680,21 @@ export function LicenseReviewPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
<Table withTableBorder>
|
<Table withTableBorder>
|
||||||
<Table.Tbody>
|
<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.Tr key={k}>
|
||||||
<Table.Td w="40%">
|
<Table.Td w="40%">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{k}
|
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Table.Tbody>
|
</Table.Tbody>
|
||||||
</Table>
|
</Table>
|
||||||
{flags[sectionKey] && (
|
{flags[sectionKey] && (
|
||||||
@@ -703,7 +731,8 @@ export function LicenseReviewPage() {
|
|||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</Card>
|
</Card>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</Stack>
|
</Stack>
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
@@ -779,19 +808,13 @@ export function LicenseReviewPage() {
|
|||||||
{data.staff.map((member) => (
|
{data.staff.map((member) => (
|
||||||
<Table.Tr key={member.id}>
|
<Table.Tr key={member.id}>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="xs">{member.roleKey}</Text>
|
<Text size="xs">{localized(roleNameByKey.get(member.roleKey)) || member.roleKey}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm">{member.fullName}</Text>
|
<Text size="sm">{member.fullName}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Group gap={4}>
|
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
|
||||||
{(member.documents ?? []).map((doc) => (
|
|
||||||
<Badge key={doc.id} size="xs" variant="light">
|
|
||||||
{doc.documentKey}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</Group>
|
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
{/* A person's papers are as returnable as a document
|
{/* A person's papers are as returnable as a document
|
||||||
or a form section: an ERB certificate for the wrong
|
or a form section: an ERB certificate for the wrong
|
||||||
@@ -853,7 +876,7 @@ export function LicenseReviewPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
{inspection.scheduledDate
|
{inspection.scheduledDate
|
||||||
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
|
? showDate(inspection.scheduledDate)
|
||||||
: t('review.unscheduled', 'Not scheduled')}
|
: t('review.unscheduled', 'Not scheduled')}
|
||||||
</Text>
|
</Text>
|
||||||
{inspection.findings && (
|
{inspection.findings && (
|
||||||
@@ -866,7 +889,11 @@ export function LicenseReviewPage() {
|
|||||||
variant="light"
|
variant="light"
|
||||||
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
|
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>
|
</Badge>
|
||||||
</Group>
|
</Group>
|
||||||
))}
|
))}
|
||||||
@@ -921,13 +948,13 @@ export function LicenseReviewPage() {
|
|||||||
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="datetime-local"
|
|
||||||
label={t('review.dateTime', 'Date and time')}
|
label={t('review.dateTime', 'Date and time')}
|
||||||
value={inspectionDate}
|
value={inspectionDate}
|
||||||
onChange={(e) => setInspectionDate(e.currentTarget.value)}
|
onChange={setInspectionDate}
|
||||||
|
withTime
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Tooltip
|
<Tooltip
|
||||||
label={t('review.pickDate', 'Pick a date and time first')}
|
label={t('review.pickDate', 'Pick a date and time first')}
|
||||||
disabled={Boolean(inspectionDate)}
|
disabled={Boolean(inspectionDate)}
|
||||||
@@ -949,7 +976,7 @@ export function LicenseReviewPage() {
|
|||||||
run(async () => {
|
run(async () => {
|
||||||
await scheduleInspection({
|
await scheduleInspection({
|
||||||
applicationId: id,
|
applicationId: id,
|
||||||
scheduledDate: new Date(inspectionDate).toISOString(),
|
scheduledDate: inspectionDate,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
setInspectionOpen(false);
|
setInspectionOpen(false);
|
||||||
}, t('review.done.scheduled', 'Inspection scheduled'))
|
}, t('review.done.scheduled', 'Inspection scheduled'))
|
||||||
@@ -957,7 +984,7 @@ export function LicenseReviewPage() {
|
|||||||
>
|
>
|
||||||
<IconCheck size={18} />
|
<IconCheck size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
@@ -972,7 +999,7 @@ export function LicenseReviewPage() {
|
|||||||
<Stack gap={6}>
|
<Stack gap={6}>
|
||||||
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
||||||
<Group key={item.key} justify="space-between" wrap="nowrap">
|
<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
|
<SegmentedControl
|
||||||
size="xs"
|
size="xs"
|
||||||
value={checklist[item.key] ?? 'PASS'}
|
value={checklist[item.key] ?? 'PASS'}
|
||||||
@@ -1005,7 +1032,7 @@ export function LicenseReviewPage() {
|
|||||||
autosize
|
autosize
|
||||||
minRows={3}
|
minRows={3}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<ModalFooter grow>
|
||||||
<ActionIcon
|
<ActionIcon
|
||||||
variant="light"
|
variant="light"
|
||||||
color="teal"
|
color="teal"
|
||||||
@@ -1054,13 +1081,58 @@ export function LicenseReviewPage() {
|
|||||||
>
|
>
|
||||||
<IconX size={18} />
|
<IconX size={18} />
|
||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Container>
|
</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 }) {
|
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
|
||||||
return (
|
return (
|
||||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { LicenseApplication } from '@ema-platform/api';
|
import type { LicenseApplication } from '@ema-platform/api';
|
||||||
|
import { dateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
/** Amber once this much of the window has been consumed. */
|
/** Amber once this much of the window has been consumed. */
|
||||||
const WARNING_RATIO = 0.7;
|
const WARNING_RATIO = 0.7;
|
||||||
@@ -17,6 +18,19 @@ export interface SlaState {
|
|||||||
ratio: number;
|
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 {
|
function formatDuration(ms: number): string {
|
||||||
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
||||||
if (hours < 1) return '<1h';
|
if (hours < 1) return '<1h';
|
||||||
@@ -35,6 +49,8 @@ function formatDuration(ms: number): string {
|
|||||||
export function computeSla(
|
export function computeSla(
|
||||||
application: LicenseApplication,
|
application: LicenseApplication,
|
||||||
now: number = Date.now(),
|
now: number = Date.now(),
|
||||||
|
language = 'en',
|
||||||
|
t?: SlaTranslate,
|
||||||
): SlaState {
|
): SlaState {
|
||||||
const slaHours = application.licenseType?.slaHours;
|
const slaHours = application.licenseType?.slaHours;
|
||||||
const submittedAt = application.submittedAt;
|
const submittedAt = application.submittedAt;
|
||||||
@@ -44,7 +60,7 @@ export function computeSla(
|
|||||||
state: 'untracked',
|
state: 'untracked',
|
||||||
color: 'gray',
|
color: 'gray',
|
||||||
label: '—',
|
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,
|
ratio: 0,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -54,15 +70,23 @@ export function computeSla(
|
|||||||
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
||||||
const window = slaHours * HOUR_MS;
|
const window = slaHours * HOUR_MS;
|
||||||
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
|
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) {
|
if (application.decidedAt) {
|
||||||
const met = elapsed <= window;
|
const met = elapsed <= window;
|
||||||
return {
|
return {
|
||||||
state: 'decided',
|
state: 'decided',
|
||||||
color: met ? 'teal' : 'gray',
|
color: met ? 'teal' : 'gray',
|
||||||
label: met ? 'Met' : 'Missed',
|
label: tr(t, met ? 'review.sla.met' : 'review.sla.missed', met ? 'Met' : 'Missed'),
|
||||||
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
|
tooltip: tr(t, 'review.sla.decidedIn', {
|
||||||
|
duration: formatDuration(elapsed),
|
||||||
|
target: targetText,
|
||||||
|
defaultValue: 'Decided in {{duration}}. {{target}}',
|
||||||
|
}),
|
||||||
ratio,
|
ratio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -72,8 +96,15 @@ export function computeSla(
|
|||||||
return {
|
return {
|
||||||
state: 'breached',
|
state: 'breached',
|
||||||
color: 'red',
|
color: 'red',
|
||||||
label: `Overdue ${formatDuration(remaining)}`,
|
label: tr(t, 'review.sla.overdue', {
|
||||||
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
|
duration: formatDuration(remaining),
|
||||||
|
defaultValue: 'Overdue {{duration}}',
|
||||||
|
}),
|
||||||
|
tooltip: tr(t, 'review.sla.overdueBy', {
|
||||||
|
duration: formatDuration(remaining),
|
||||||
|
target: targetText,
|
||||||
|
defaultValue: 'Overdue by {{duration}}. {{target}}',
|
||||||
|
}),
|
||||||
ratio: 1,
|
ratio: 1,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -82,8 +113,15 @@ export function computeSla(
|
|||||||
return {
|
return {
|
||||||
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
||||||
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
||||||
label: `${formatDuration(remaining)} left`,
|
label: tr(t, 'review.sla.left', {
|
||||||
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
|
duration: formatDuration(remaining),
|
||||||
|
defaultValue: '{{duration}} left',
|
||||||
|
}),
|
||||||
|
tooltip: tr(t, 'review.sla.remaining', {
|
||||||
|
duration: formatDuration(remaining),
|
||||||
|
target: targetText,
|
||||||
|
defaultValue: '{{duration}} remaining. {{target}}',
|
||||||
|
}),
|
||||||
ratio,
|
ratio,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import {
|
|||||||
Box,
|
Box,
|
||||||
rem,
|
rem,
|
||||||
Center,
|
Center,
|
||||||
|
useMantineColorScheme,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconChevronRight,
|
IconChevronRight,
|
||||||
@@ -21,6 +22,7 @@ import {
|
|||||||
import { useGetLocationsQuery } from '../api/location-api';
|
import { useGetLocationsQuery } from '../api/location-api';
|
||||||
import type { Location } from '../types/location';
|
import type { Location } from '../types/location';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
|
import { useLocalized } from '@ema-platform/api';
|
||||||
|
|
||||||
interface LocationTreeProps {
|
interface LocationTreeProps {
|
||||||
selectedId: string | null;
|
selectedId: string | null;
|
||||||
@@ -50,10 +52,15 @@ function TreeNode({
|
|||||||
onSelect: (location: Location) => void;
|
onSelect: (location: Location) => void;
|
||||||
depth: number;
|
depth: number;
|
||||||
}) {
|
}) {
|
||||||
|
const localized = useLocalized();
|
||||||
const [opened, setOpened] = useState(depth < 1);
|
const [opened, setOpened] = useState(depth < 1);
|
||||||
const isSelected = selectedId === location.id;
|
const isSelected = selectedId === location.id;
|
||||||
const hasChildren =
|
const hasChildren =
|
||||||
Array.isArray(location.children) && location.children.length > 0;
|
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(() => {
|
const toggle = useCallback(() => {
|
||||||
setOpened((prev) => !prev);
|
setOpened((prev) => !prev);
|
||||||
@@ -88,8 +95,7 @@ function TreeNode({
|
|||||||
}}
|
}}
|
||||||
onMouseEnter={(e) => {
|
onMouseEnter={(e) => {
|
||||||
if (!isSelected)
|
if (!isSelected)
|
||||||
e.currentTarget.style.backgroundColor =
|
e.currentTarget.style.backgroundColor = hoverBg;
|
||||||
'var(--mantine-color-gray-0)';
|
|
||||||
}}
|
}}
|
||||||
onMouseLeave={(e) => {
|
onMouseLeave={(e) => {
|
||||||
if (!isSelected)
|
if (!isSelected)
|
||||||
@@ -130,7 +136,7 @@ function TreeNode({
|
|||||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||||
/>
|
/>
|
||||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||||
{location.names.en}
|
{localized(location.names)}
|
||||||
</Text>
|
</Text>
|
||||||
</UnstyledButton>
|
</UnstyledButton>
|
||||||
{hasChildren && (
|
{hasChildren && (
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ import {
|
|||||||
useUpdateLocationTypeMutation,
|
useUpdateLocationTypeMutation,
|
||||||
useDeleteLocationTypeMutation,
|
useDeleteLocationTypeMutation,
|
||||||
} from '../api/location-api';
|
} from '../api/location-api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||||
|
|
||||||
interface LocationTypeFormValues {
|
interface LocationTypeFormValues {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -34,6 +34,7 @@ interface LocationTypeFormValues {
|
|||||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
||||||
const [createType] = useCreateLocationTypeMutation();
|
const [createType] = useCreateLocationTypeMutation();
|
||||||
const [updateType] = useUpdateLocationTypeMutation();
|
const [updateType] = useUpdateLocationTypeMutation();
|
||||||
@@ -84,8 +85,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
try {
|
try {
|
||||||
await deleteType(id).unwrap();
|
await deleteType(id).unwrap();
|
||||||
notify.success(t('location.typeDeleted'));
|
notify.success(t('location.typeDeleted'));
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('location.deleteError'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -99,8 +100,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
|||||||
notify.success(t('location.typeCreated'));
|
notify.success(t('location.typeCreated'));
|
||||||
}
|
}
|
||||||
resetForm();
|
resetForm();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('location.typeError'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from '@mantine/hooks';
|
||||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||||
import { LocationTree } from '../components/LocationTree';
|
import { LocationTree } from '../components/LocationTree';
|
||||||
import { LocationDetail } from '../components/LocationDetail';
|
import { LocationDetail } from '../components/LocationDetail';
|
||||||
import { LocationForm } from '../components/LocationForm';
|
import { LocationForm } from '../components/LocationForm';
|
||||||
@@ -33,6 +33,7 @@ import type { Location } from '../types/location';
|
|||||||
export function LocationPage() {
|
export function LocationPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||||
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
||||||
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
||||||
@@ -82,11 +83,11 @@ export function LocationPage() {
|
|||||||
closeFormModal();
|
closeFormModal();
|
||||||
setEditingLocation(null);
|
setEditingLocation(null);
|
||||||
setParentLocation(null);
|
setParentLocation(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('location.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[editingLocation, createLocation, updateLocation, closeFormModal, t],
|
[editingLocation, createLocation, updateLocation, closeFormModal, handleError],
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleDeleteConfirm = useCallback(async () => {
|
const handleDeleteConfirm = useCallback(async () => {
|
||||||
@@ -96,10 +97,10 @@ export function LocationPage() {
|
|||||||
notify.success(t('location.deleted'));
|
notify.success(t('location.deleted'));
|
||||||
setSelectedLocation(null);
|
setSelectedLocation(null);
|
||||||
closeDeleteModal();
|
closeDeleteModal();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('location.deleteError'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
|
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
|
||||||
|
|
||||||
if (typesLoading) {
|
if (typesLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -225,14 +226,14 @@ export function LocationPage() {
|
|||||||
name: selectedLocation?.names[locale] ?? '',
|
name: selectedLocation?.names[locale] ?? '',
|
||||||
})}
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDeleteModal} size="sm">
|
<Button variant="default" onClick={closeDeleteModal} size="sm">
|
||||||
{t('location.cancel')}
|
{t('location.cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="red" onClick={handleDeleteConfirm} size="sm">
|
<Button color="red" onClick={handleDeleteConfirm} size="sm">
|
||||||
{t('location.delete')}
|
{t('location.delete')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
|
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
import { useState } from 'react';
|
import { useCallback, useMemo, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Modal,
|
Modal,
|
||||||
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
Textarea,
|
Textarea,
|
||||||
@@ -18,12 +18,17 @@ import {
|
|||||||
import {
|
import {
|
||||||
IconAnchor,
|
IconAnchor,
|
||||||
IconCheck,
|
IconCheck,
|
||||||
|
IconEye,
|
||||||
|
IconInbox,
|
||||||
|
IconPaperclip,
|
||||||
IconStethoscope,
|
IconStethoscope,
|
||||||
IconX,
|
IconX,
|
||||||
} from '@tabler/icons-react';
|
} 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 {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
useGetAttachmentsQuery,
|
||||||
useGetPendingMedicalQuery,
|
useGetPendingMedicalQuery,
|
||||||
useGetPendingSeaServiceQuery,
|
useGetPendingSeaServiceQuery,
|
||||||
useVerifyMedicalCertificateMutation,
|
useVerifyMedicalCertificateMutation,
|
||||||
@@ -35,6 +40,8 @@ import type {
|
|||||||
SeafarerProfileSummary,
|
SeafarerProfileSummary,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
|
const PAGE_SIZE = 10;
|
||||||
|
|
||||||
function ownerName(profile?: SeafarerProfileSummary): string {
|
function ownerName(profile?: SeafarerProfileSummary): string {
|
||||||
if (!profile) return '—';
|
if (!profile) return '—';
|
||||||
return (
|
return (
|
||||||
@@ -58,12 +65,13 @@ function RejectModal({
|
|||||||
onConfirm: (remark: string) => void;
|
onConfirm: (remark: string) => void;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [remark, setRemark] = useState('');
|
const [remark, setRemark] = useState('');
|
||||||
return (
|
return (
|
||||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="What must the seafarer fix?"
|
label={t('recordVerification.rejectReasonLabel', 'What must the seafarer fix?')}
|
||||||
required
|
required
|
||||||
minRows={2}
|
minRows={2}
|
||||||
value={remark}
|
value={remark}
|
||||||
@@ -71,7 +79,7 @@ function RejectModal({
|
|||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
Cancel
|
{t('recordVerification.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color="red"
|
color="red"
|
||||||
@@ -82,7 +90,7 @@ function RejectModal({
|
|||||||
setRemark('');
|
setRemark('');
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
Reject
|
{t('recordVerification.reject', 'Reject')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</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
|
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||||
@@ -97,234 +192,383 @@ function RejectModal({
|
|||||||
* certificate starts satisfying the submission gate.
|
* certificate starts satisfying the submission gate.
|
||||||
*/
|
*/
|
||||||
export function MedicalVerificationPage() {
|
export function MedicalVerificationPage() {
|
||||||
const { data: pendingMedical, isLoading: loadingMedical } =
|
const { t } = useTranslation();
|
||||||
useGetPendingMedicalQuery();
|
const {
|
||||||
const { data: pendingSeaService, isLoading: loadingSeaService } =
|
data: pendingMedical,
|
||||||
useGetPendingSeaServiceQuery();
|
isLoading: loadingMedical,
|
||||||
|
isFetching: fetchingMedical,
|
||||||
|
refetch: refetchMedical,
|
||||||
|
} = useGetPendingMedicalQuery();
|
||||||
|
|
||||||
|
const {
|
||||||
|
data: pendingSeaService,
|
||||||
|
isLoading: loadingSeaService,
|
||||||
|
isFetching: fetchingSeaService,
|
||||||
|
refetch: refetchSeaService,
|
||||||
|
} = useGetPendingSeaServiceQuery();
|
||||||
|
|
||||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||||
useVerifyMedicalCertificateMutation();
|
useVerifyMedicalCertificateMutation();
|
||||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||||
useVerifySeaServiceRecordMutation();
|
useVerifySeaServiceRecordMutation();
|
||||||
|
|
||||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||||
null,
|
null,
|
||||||
);
|
);
|
||||||
const [rejectSeaService, setRejectSeaService] =
|
const [rejectSeaService, setRejectSeaService] =
|
||||||
useState<SeaServiceRecord | null>(null);
|
useState<SeaServiceRecord | null>(null);
|
||||||
|
|
||||||
const rule = async (
|
const [attachmentModal, setAttachmentModal] = useState<{
|
||||||
run: () => Promise<unknown>,
|
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
||||||
done: string,
|
ownerId: string;
|
||||||
): Promise<void> => {
|
title: string;
|
||||||
try {
|
} | null>(null);
|
||||||
await run();
|
|
||||||
notify.success(done);
|
const showDate = useDateDisplayer();
|
||||||
} catch (error) {
|
|
||||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
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 (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Title order={3} mb={4}>
|
<Title order={3} mb={4}>
|
||||||
Record verification
|
{t('recordVerification.title', 'Record verification')}
|
||||||
</Title>
|
</Title>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Text size="sm" c="dimmed" mb="md">
|
||||||
Submitted sea-service records and medical certificates awaiting a
|
{t(
|
||||||
ruling. Verified records are frozen; rejections return to the seafarer
|
'recordVerification.subtitle',
|
||||||
with your remark.
|
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
|
|
||||||
<Tabs defaultValue="medical" keepMounted={false}>
|
<Tabs defaultValue="medical" keepMounted={false}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
<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>
|
||||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
<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.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
<Tabs.Panel value="medical" pt="md">
|
<Tabs.Panel value="medical" pt="md">
|
||||||
<Card withBorder padding={0}>
|
<AdvancedTable
|
||||||
{loadingMedical ? (
|
columns={medicalColumns}
|
||||||
<Center h={160}>
|
data={pagedMedical}
|
||||||
<Loader />
|
tableName={t('recordVerification.tabs.medical', {
|
||||||
</Center>
|
count: pendingMedicalList.length,
|
||||||
) : (pendingMedical ?? []).length === 0 ? (
|
defaultValue: 'Medical ({{count}})',
|
||||||
<Center h={120}>
|
})}
|
||||||
<Text size="sm" c="dimmed">
|
itemCount={pendingMedicalList.length}
|
||||||
Nothing awaiting verification.
|
pageIndex={medicalPage}
|
||||||
</Text>
|
onPageChange={setMedicalPage}
|
||||||
</Center>
|
pageSize={medicalPageSize}
|
||||||
) : (
|
onPageSizeChange={handleMedicalPageSizeChange}
|
||||||
<Table highlightOnHover>
|
refresh={refetchMedical}
|
||||||
<Table.Thead>
|
isLoading={loadingMedical || fetchingMedical}
|
||||||
<Table.Tr>
|
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||||
<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>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
|
|
||||||
<Tabs.Panel value="sea-service" pt="md">
|
<Tabs.Panel value="sea-service" pt="md">
|
||||||
<Card withBorder padding={0}>
|
<AdvancedTable
|
||||||
{loadingSeaService ? (
|
columns={seaServiceColumns}
|
||||||
<Center h={160}>
|
data={pagedSeaService}
|
||||||
<Loader />
|
tableName={t('recordVerification.tabs.seaService', {
|
||||||
</Center>
|
count: pendingSeaServiceList.length,
|
||||||
) : (pendingSeaService ?? []).length === 0 ? (
|
defaultValue: 'Sea Service ({{count}})',
|
||||||
<Center h={120}>
|
})}
|
||||||
<Text size="sm" c="dimmed">
|
itemCount={pendingSeaServiceList.length}
|
||||||
Nothing awaiting verification.
|
pageIndex={seaServicePage}
|
||||||
</Text>
|
onPageChange={setSeaServicePage}
|
||||||
</Center>
|
pageSize={seaServicePageSize}
|
||||||
) : (
|
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||||
<Table highlightOnHover>
|
refresh={refetchSeaService}
|
||||||
<Table.Thead>
|
isLoading={loadingSeaService || fetchingSeaService}
|
||||||
<Table.Tr>
|
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||||
<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>
|
|
||||||
</Tabs.Panel>
|
</Tabs.Panel>
|
||||||
</Tabs>
|
</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
|
<RejectModal
|
||||||
title="Reject medical certificate"
|
title={t('recordVerification.rejectMedicalTitle', 'Reject medical certificate')}
|
||||||
opened={Boolean(rejectMedical)}
|
opened={Boolean(rejectMedical)}
|
||||||
onClose={() => setRejectMedical(null)}
|
onClose={() => setRejectMedical(null)}
|
||||||
loading={rulingMedical}
|
loading={rulingMedical}
|
||||||
@@ -337,13 +581,13 @@ export function MedicalVerificationPage() {
|
|||||||
outcome: 'REJECTED',
|
outcome: 'REJECTED',
|
||||||
remark,
|
remark,
|
||||||
}).unwrap(),
|
}).unwrap(),
|
||||||
'Certificate rejected',
|
t('recordVerification.certificateRejected', 'Certificate rejected'),
|
||||||
);
|
);
|
||||||
setRejectMedical(null);
|
setRejectMedical(null);
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<RejectModal
|
<RejectModal
|
||||||
title="Reject sea-service record"
|
title={t('recordVerification.rejectSeaServiceTitle', 'Reject sea-service record')}
|
||||||
opened={Boolean(rejectSeaService)}
|
opened={Boolean(rejectSeaService)}
|
||||||
onClose={() => setRejectSeaService(null)}
|
onClose={() => setRejectSeaService(null)}
|
||||||
loading={rulingSeaService}
|
loading={rulingSeaService}
|
||||||
@@ -356,7 +600,7 @@ export function MedicalVerificationPage() {
|
|||||||
outcome: 'REJECTED',
|
outcome: 'REJECTED',
|
||||||
remark,
|
remark,
|
||||||
}).unwrap(),
|
}).unwrap(),
|
||||||
'Sea-service record rejected',
|
t('recordVerification.seaServiceRejected', 'Sea-service record rejected'),
|
||||||
);
|
);
|
||||||
setRejectSeaService(null);
|
setRejectSeaService(null);
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Alert,
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
|
||||||
Center,
|
Center,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -12,7 +12,6 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Stack,
|
Stack,
|
||||||
Switch,
|
Switch,
|
||||||
Table,
|
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
@@ -26,10 +25,16 @@ import {
|
|||||||
IconInfoCircle,
|
IconInfoCircle,
|
||||||
IconLock,
|
IconLock,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import {
|
||||||
|
notify,
|
||||||
|
ModalFooter,
|
||||||
|
AdvancedTable,
|
||||||
|
useServerTable,
|
||||||
|
type AdvancedColumn,
|
||||||
|
} from '@ema-platform/ui';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
localized,
|
useLocalized,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
useUpdateLicenseFeesMutation,
|
useUpdateLicenseFeesMutation,
|
||||||
@@ -55,9 +60,13 @@ function feeText(amount: string | number | null, currency: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function PaymentConfigPage() {
|
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 { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||||
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
@@ -72,7 +81,7 @@ export function PaymentConfigPage() {
|
|||||||
<Alert
|
<Alert
|
||||||
color="red"
|
color="red"
|
||||||
icon={<IconAlertTriangle size={18} />}
|
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>
|
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
@@ -82,15 +91,113 @@ export function PaymentConfigPage() {
|
|||||||
const types = [...(data?.items ?? [])].sort(
|
const types = [...(data?.items ?? [])].sort(
|
||||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
(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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-start">
|
<Group justify="space-between" align="flex-start">
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>Payment configuration</Title>
|
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||||
<Text size="sm" c="dimmed" mt={4}>
|
<Text size="sm" c="dimmed" mt={4}>
|
||||||
What each licence costs. Applicants are charged after approval, and
|
{t(
|
||||||
the amount is fixed onto the application at that moment.
|
'paymentConfig.subtitle',
|
||||||
|
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<ThemeIcon size="xl" radius="md" variant="light">
|
<ThemeIcon size="xl" radius="md" variant="light">
|
||||||
@@ -105,90 +212,25 @@ export function PaymentConfigPage() {
|
|||||||
icon={<IconInfoCircle size={18} />}
|
icon={<IconInfoCircle size={18} />}
|
||||||
>
|
>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
A change applies to applications approved from now on. Anything
|
{t(
|
||||||
already approved keeps the amount it was quoted, so an edit here can
|
'paymentConfig.changeNotice',
|
||||||
never alter what an applicant has already been asked to pay.
|
'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>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
|
|
||||||
<Card withBorder radius="md" padding={0}>
|
<AdvancedTable
|
||||||
<Table.ScrollContainer minWidth={820}>
|
tableName="payment-config-license-types"
|
||||||
<Table highlightOnHover verticalSpacing="sm">
|
columns={columns}
|
||||||
<Table.Thead>
|
data={page.rows}
|
||||||
<Table.Tr>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>Licence type</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>New application</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>Renewal</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th>Charged?</Table.Th>
|
onPageSizeChange={setPageSize}
|
||||||
<Table.Th w={90} />
|
refresh={refetch}
|
||||||
</Table.Tr>
|
isLoading={isFetching}
|
||||||
</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>
|
|
||||||
|
|
||||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||||
|
|
||||||
@@ -203,6 +245,7 @@ export function PaymentConfigPage() {
|
|||||||
* them as editable fields would be a lie.
|
* them as editable fields would be a lie.
|
||||||
*/
|
*/
|
||||||
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||||
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Paper withBorder radius="md" p="md">
|
<Paper withBorder radius="md" p="md">
|
||||||
<Group gap="xs" mb="xs">
|
<Group gap="xs" mb="xs">
|
||||||
@@ -210,23 +253,31 @@ function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
|||||||
<IconLock size={13} />
|
<IconLock size={13} />
|
||||||
</ThemeIcon>
|
</ThemeIcon>
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
Payment gateway
|
{t('paymentConfig.gateway.title', 'Payment gateway')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<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>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
<Badge variant="light">Telebirr</Badge>
|
<Badge variant="light">{t('paymentConfig.gateway.provider', 'Telebirr')}</Badge>
|
||||||
{bypassEnabled ? (
|
{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">
|
<Badge variant="light" color="orange">
|
||||||
Test bypass enabled
|
{t('paymentConfig.gateway.bypassEnabled', 'Test bypass enabled')}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : (
|
) : (
|
||||||
<Badge variant="light" color="gray">
|
<Badge variant="light" color="gray">
|
||||||
Test bypass off
|
{t('paymentConfig.gateway.bypassOff', 'Test bypass off')}
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
</Group>
|
</Group>
|
||||||
@@ -241,6 +292,8 @@ function FeeEditModal({
|
|||||||
licenseType: LicenseType | null;
|
licenseType: LicenseType | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
||||||
const [newFee, setNewFee] = useState<number | ''>('');
|
const [newFee, setNewFee] = useState<number | ''>('');
|
||||||
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
||||||
@@ -268,11 +321,21 @@ function FeeEditModal({
|
|||||||
async function save() {
|
async function save() {
|
||||||
if (!licenseType) return;
|
if (!licenseType) return;
|
||||||
if (chargeable && newFee === '') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
if (chargeable && !sameAsNew && renewalFee === '') {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -282,10 +345,17 @@ function FeeEditModal({
|
|||||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||||
feeCurrency: currency.trim() || 'ETB',
|
feeCurrency: currency.trim() || 'ETB',
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
notify.success(`${localized(licenseType.name)} fees updated.`);
|
notify.success(
|
||||||
|
t('paymentConfig.modal.updated', {
|
||||||
|
type: localized(licenseType.name),
|
||||||
|
defaultValue: '{{type}} fees updated.',
|
||||||
|
}),
|
||||||
|
);
|
||||||
onClose();
|
onClose();
|
||||||
} catch (err) {
|
} 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
|
<Modal
|
||||||
opened={!!licenseType}
|
opened={!!licenseType}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
title={
|
||||||
|
licenseType
|
||||||
|
? t('paymentConfig.modal.title', {
|
||||||
|
type: localized(licenseType.name),
|
||||||
|
defaultValue: 'Fees — {{type}}',
|
||||||
|
})
|
||||||
|
: ''
|
||||||
|
}
|
||||||
centered
|
centered
|
||||||
radius="lg"
|
radius="lg"
|
||||||
>
|
>
|
||||||
@@ -306,9 +383,10 @@ function FeeEditModal({
|
|||||||
icon={<IconInfoCircle size={16} />}
|
icon={<IconInfoCircle size={16} />}
|
||||||
>
|
>
|
||||||
<Text size="sm">
|
<Text size="sm">
|
||||||
This licence type concludes with an EMA decision and never
|
{t(
|
||||||
reaches a payment stage, so a fee set here stays unused until
|
'paymentConfig.modal.noPaymentStage',
|
||||||
that changes.
|
'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>
|
</Text>
|
||||||
</Alert>
|
</Alert>
|
||||||
)}
|
)}
|
||||||
@@ -316,14 +394,17 @@ function FeeEditModal({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={chargeable}
|
checked={chargeable}
|
||||||
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
||||||
label="This licence carries a fee"
|
label={t('paymentConfig.modal.chargeableLabel', 'This licence carries a fee')}
|
||||||
description="Turn off for licence types applicants are never charged for."
|
description={t(
|
||||||
|
'paymentConfig.modal.chargeableDescription',
|
||||||
|
'Turn off for licence types applicants are never charged for.',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{chargeable && (
|
{chargeable && (
|
||||||
<>
|
<>
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="New application fee"
|
label={t('paymentConfig.modal.newFeeLabel', 'New application fee')}
|
||||||
value={newFee}
|
value={newFee}
|
||||||
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -336,13 +417,16 @@ function FeeEditModal({
|
|||||||
<Switch
|
<Switch
|
||||||
checked={sameAsNew}
|
checked={sameAsNew}
|
||||||
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
||||||
label="Charge renewal at the same rate"
|
label={t('paymentConfig.modal.sameRateLabel', 'Charge renewal at the same rate')}
|
||||||
description="Turn off to set a separate renewal fee."
|
description={t(
|
||||||
|
'paymentConfig.modal.sameRateDescription',
|
||||||
|
'Turn off to set a separate renewal fee.',
|
||||||
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{!sameAsNew && (
|
{!sameAsNew && (
|
||||||
<NumberInput
|
<NumberInput
|
||||||
label="Renewal fee"
|
label={t('paymentConfig.modal.renewalFeeLabel', 'Renewal fee')}
|
||||||
value={renewalFee}
|
value={renewalFee}
|
||||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||||
min={0}
|
min={0}
|
||||||
@@ -354,7 +438,7 @@ function FeeEditModal({
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Currency"
|
label={t('paymentConfig.modal.currencyLabel', 'Currency')}
|
||||||
value={currency}
|
value={currency}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
setCurrency(e.currentTarget.value.toUpperCase())
|
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}>
|
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||||
Cancel
|
{t('paymentConfig.modal.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button onClick={save} loading={isLoading}>
|
<Button onClick={save} loading={isLoading}>
|
||||||
Save fees
|
{t('paymentConfig.modal.save', 'Save fees')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|||||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
|||||||
import { zodResolver } from '@hookform/resolvers/zod';
|
import { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
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 { useApiMutation } from '@ema-platform/api';
|
||||||
import { setUser } from '@ema-platform/auth';
|
import { setUser } from '@ema-platform/auth';
|
||||||
import type { AuthUser } 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 user = useAppSelector((state) => state.auth.user);
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
|
||||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
const [meTrigger] = useApiMutation<AuthUser>();
|
||||||
@@ -156,8 +157,8 @@ export function ProfilePage() {
|
|||||||
dispatch(setUser(me));
|
dispatch(setUser(me));
|
||||||
|
|
||||||
notify.success(t('profile.profileUpdated'));
|
notify.success(t('profile.profileUpdated'));
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('profile.updateFailed'));
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingProfile(false);
|
setIsSavingProfile(false);
|
||||||
}
|
}
|
||||||
@@ -169,12 +170,10 @@ export function ProfilePage() {
|
|||||||
oldPassword: z
|
oldPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
newPassword: z
|
newPassword: strongPasswordSchema(12),
|
||||||
.string()
|
|
||||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
|
||||||
confirmPassword: z
|
confirmPassword: z
|
||||||
.string()
|
.string()
|
||||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
message: t('profile.validation.passwordMismatch'),
|
message: t('profile.validation.passwordMismatch'),
|
||||||
@@ -208,8 +207,8 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
notify.success(t('profile.passwordChanged'));
|
notify.success(t('profile.passwordChanged'));
|
||||||
resetPassword();
|
resetPassword();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('profile.passwordFailed'));
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingPassword(false);
|
setIsSavingPassword(false);
|
||||||
}
|
}
|
||||||
@@ -413,12 +412,15 @@ export function ProfilePage() {
|
|||||||
{...registerPassword('oldPassword')}
|
{...registerPassword('oldPassword')}
|
||||||
/>
|
/>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label={t('profile.fields.newPassword')}
|
<PasswordInput
|
||||||
leftSection={<IconLock size={18} />}
|
label={t('profile.fields.newPassword')}
|
||||||
error={passwordErrors.newPassword?.message}
|
leftSection={<IconLock size={18} />}
|
||||||
{...registerPassword('newPassword')}
|
error={passwordErrors.newPassword?.message}
|
||||||
/>
|
{...registerPassword('newPassword')}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('profile.fields.confirmPassword')}
|
label={t('profile.fields.confirmPassword')}
|
||||||
leftSection={<IconLock size={18} />}
|
leftSection={<IconLock size={18} />}
|
||||||
|
|||||||
@@ -4,15 +4,12 @@ import {
|
|||||||
Title,
|
Title,
|
||||||
Group,
|
Group,
|
||||||
Button,
|
Button,
|
||||||
Table,
|
|
||||||
Badge,
|
Badge,
|
||||||
ActionIcon,
|
ActionIcon,
|
||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
Paper,
|
Card,
|
||||||
Loader,
|
|
||||||
Center,
|
|
||||||
Alert,
|
Alert,
|
||||||
Select,
|
Select,
|
||||||
NumberInput,
|
NumberInput,
|
||||||
@@ -28,7 +25,7 @@ import {
|
|||||||
IconSend,
|
IconSend,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
} from '@tabler/icons-react';
|
} 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 { extractErrorMessage } from '@ema-platform/api';
|
||||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||||
import {
|
import {
|
||||||
@@ -95,7 +92,7 @@ function QuestionForm({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
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}>
|
<form onSubmit={handleSubmit}>
|
||||||
<Stack gap="sm">
|
<Stack gap="sm">
|
||||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
<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.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" />
|
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||||
</Group>
|
</Group>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
<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>
|
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</form>
|
</form>
|
||||||
</Paper>
|
</Modal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function QuestionPage() {
|
export function QuestionPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
const { data: certRes } = useGetCertificationsQuery();
|
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 [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||||
const [deleteQ] = useDeleteQuestionMutation();
|
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 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 filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||||
|
const page = paginate(filtered);
|
||||||
|
|
||||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||||
|
|
||||||
@@ -211,14 +211,93 @@ export function QuestionPage() {
|
|||||||
notify.success(t('question.deleted'));
|
notify.success(t('question.deleted'));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('question.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<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">
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Text fw={600}>{t('question.pool')}</Text>
|
<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>
|
</Group>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('question.columns.title')}</Table.Th>
|
tableName={t('question.title')}
|
||||||
<Table.Th>{t('question.columns.certification')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('question.columns.form')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t('question.columns.points')}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t('question.qc.column')}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th />
|
onPageSizeChange={setPageSize}
|
||||||
</Table.Tr>
|
refresh={refetch}
|
||||||
</Table.Thead>
|
isLoading={isFetching}
|
||||||
<Table.Tbody>
|
emptyText={t('question.noQuestions')}
|
||||||
{filtered.map((q) => (
|
/>
|
||||||
<Table.Tr key={q.id}>
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={Boolean(reviewTarget)}
|
opened={Boolean(reviewTarget)}
|
||||||
@@ -367,10 +381,10 @@ export function QuestionPage() {
|
|||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ export function RecordResultModal({
|
|||||||
}) {
|
}) {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||||
const [scores, setScores] = useState<Record<string, number>>({});
|
const [scores, setScores] = useState<Record<string, number>>({});
|
||||||
@@ -227,12 +228,12 @@ export function RecordResultModal({
|
|||||||
size="sm"
|
size="sm"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
||||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||||
{t('result.saveResult')}
|
{t('result.saveResult')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
|
import { IconGavel, IconInfoCircle } from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetPendingAppealsQuery,
|
useGetPendingAppealsQuery,
|
||||||
@@ -35,6 +36,7 @@ import type { ExamAppeal } from '../types/result';
|
|||||||
export function ExamAppealsPage() {
|
export function ExamAppealsPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
const { data: appeals, isLoading, isError } = useGetPendingAppealsQuery();
|
||||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||||
|
|
||||||
@@ -128,7 +130,7 @@ export function ExamAppealsPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>
|
<Text fz="xs">{showDate(appeal.createdAt)}</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Button
|
<Button
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {
|
|||||||
Modal,
|
Modal,
|
||||||
Text,
|
Text,
|
||||||
Paper,
|
Paper,
|
||||||
|
Card,
|
||||||
Loader,
|
Loader,
|
||||||
Center,
|
Center,
|
||||||
Alert,
|
Alert,
|
||||||
@@ -36,9 +37,10 @@ import {
|
|||||||
IconSearch,
|
IconSearch,
|
||||||
IconSend,
|
IconSend,
|
||||||
} from '@tabler/icons-react';
|
} 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 type { BilingualValue } from '@ema-platform/ui';
|
||||||
import { extractErrorMessage } from '@ema-platform/api';
|
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||||
import {
|
import {
|
||||||
useGetResultsQuery,
|
useGetResultsQuery,
|
||||||
useLazyGetResultQuery,
|
useLazyGetResultQuery,
|
||||||
@@ -115,9 +117,13 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
|||||||
|
|
||||||
export function ResultPage() {
|
export function ResultPage() {
|
||||||
const { t, i18n } = useTranslation();
|
const { t, i18n } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
const locale = i18n.language as 'en' | 'am';
|
const locale = i18n.language as 'en' | 'am';
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
const { data: examRes } = useGetExamsQuery();
|
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 [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||||
|
|
||||||
const exams = examRes?.items ?? [];
|
const exams = examRes?.items ?? [];
|
||||||
@@ -147,7 +153,7 @@ export function ResultPage() {
|
|||||||
const [qcRemark, setQcRemark] = useState('');
|
const [qcRemark, setQcRemark] = useState('');
|
||||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
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 startRecord = () => {
|
||||||
const ex = exams.find((e) => e.id === pickerExamId);
|
const ex = exams.find((e) => e.id === pickerExamId);
|
||||||
@@ -284,14 +290,107 @@ export function ResultPage() {
|
|||||||
notify.success(t('result.deleted'));
|
notify.success(t('result.deleted'));
|
||||||
closeDelete();
|
closeDelete();
|
||||||
setDeleteTarget(null);
|
setDeleteTarget(null);
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('result.error'));
|
handleError(e);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
|
||||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
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 (
|
return (
|
||||||
<Stack gap="lg">
|
<Stack gap="lg">
|
||||||
<Group justify="space-between" align="flex-end">
|
<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" />
|
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Paper withBorder radius="md">
|
<Card withBorder padding={0}>
|
||||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||||
<Text fw={600}>{t('result.section')}</Text>
|
<Text fw={600}>{t('result.section')}</Text>
|
||||||
<Group gap="sm" wrap="wrap">
|
<Group gap="sm" wrap="wrap">
|
||||||
@@ -332,7 +431,7 @@ export function ResultPage() {
|
|||||||
placeholder={t('result.search.seafarer')}
|
placeholder={t('result.search.seafarer')}
|
||||||
leftSection={<IconSearch size={15} />}
|
leftSection={<IconSearch size={15} />}
|
||||||
value={searchQuery}
|
value={searchQuery}
|
||||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ width: 240 }}
|
style={{ width: 240 }}
|
||||||
/>
|
/>
|
||||||
@@ -340,7 +439,7 @@ export function ResultPage() {
|
|||||||
placeholder={t('result.search.filterByExam')}
|
placeholder={t('result.search.filterByExam')}
|
||||||
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
||||||
value={examFilter}
|
value={examFilter}
|
||||||
onChange={(v) => setExamFilter(v ?? null)}
|
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
|
||||||
size="sm"
|
size="sm"
|
||||||
style={{ width: 280 }}
|
style={{ width: 280 }}
|
||||||
clearable
|
clearable
|
||||||
@@ -348,113 +447,20 @@ export function ResultPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead bg="var(--mantine-color-default-hover)">
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>{t('result.columns.seafarer')}</Table.Th>
|
tableName={t('result.title')}
|
||||||
<Table.Th>{t('result.columns.exam')}</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>{t('result.columns.totalScore')}</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>{t('result.columns.status')}</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>{t('result.review.column')}</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th>{t('result.columns.date')}</Table.Th>
|
onPageSizeChange={setPageSize}
|
||||||
<Table.Th />
|
refresh={refetch}
|
||||||
</Table.Tr>
|
isLoading={isFetching}
|
||||||
</Table.Thead>
|
emptyText={t('result.noItems')}
|
||||||
<Table.Tbody>
|
/>
|
||||||
{filtered.map((r) => (
|
</Card>
|
||||||
<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>
|
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
opened={detailOpened}
|
opened={detailOpened}
|
||||||
@@ -478,7 +484,7 @@ export function ResultPage() {
|
|||||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
<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.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.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 ?? '—'} />
|
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Paper>
|
</Paper>
|
||||||
@@ -496,7 +502,7 @@ export function ResultPage() {
|
|||||||
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
|
<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.type')} value={detailResult.exam.type ?? '—'} />
|
||||||
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
|
<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)} />
|
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
</Paper>
|
</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 variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleDetailSave}
|
onClick={handleDetailSave}
|
||||||
@@ -602,7 +608,7 @@ export function ResultPage() {
|
|||||||
>
|
>
|
||||||
{t('result.save')}
|
{t('result.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
) : (
|
) : (
|
||||||
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
|
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
|
||||||
@@ -658,10 +664,10 @@ export function ResultPage() {
|
|||||||
|
|
||||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
||||||
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
|
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
|
||||||
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
|
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
{/* Choose exam, then record */}
|
{/* Choose exam, then record */}
|
||||||
@@ -678,10 +684,10 @@ export function ResultPage() {
|
|||||||
searchable
|
searchable
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
|
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
|
||||||
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
|
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Container,
|
Container,
|
||||||
Drawer,
|
Drawer,
|
||||||
Group,
|
Group,
|
||||||
@@ -25,7 +25,8 @@ import {
|
|||||||
IconShieldCog,
|
IconShieldCog,
|
||||||
IconStethoscope,
|
IconStethoscope,
|
||||||
} from '@tabler/icons-react';
|
} 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 {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useApiQuery,
|
useApiQuery,
|
||||||
@@ -77,11 +78,13 @@ function SeafarerDetailDrawer({
|
|||||||
profile: ProfileRow | null;
|
profile: ProfileRow | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const profileId = profile?.id ?? '';
|
const profileId = profile?.id ?? '';
|
||||||
const { data: seaService, isLoading: loadingSea } =
|
const { data: seaService, isLoading: loadingSea } =
|
||||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||||
const { data: medical, isLoading: loadingMedical } =
|
const { data: medical, isLoading: loadingMedical } =
|
||||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Drawer
|
<Drawer
|
||||||
@@ -102,7 +105,7 @@ function SeafarerDetailDrawer({
|
|||||||
<Group gap="xl">
|
<Group gap="xl">
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
<Text size="xs" c="dimmed" tt="uppercase">
|
||||||
Seafarer number
|
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={700} ff="monospace">
|
<Text fw={700} ff="monospace">
|
||||||
{profile.seafarerNumber ?? '—'}
|
{profile.seafarerNumber ?? '—'}
|
||||||
@@ -110,41 +113,49 @@ function SeafarerDetailDrawer({
|
|||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
<Text size="xs" c="dimmed" tt="uppercase">
|
||||||
Department
|
{t('seafarerRegistry.drawer.department', 'Department')}
|
||||||
</Text>
|
</Text>
|
||||||
<Text fw={600}>
|
<Text fw={600}>
|
||||||
{profile.seafarerDepartment
|
{profile.seafarerDepartment
|
||||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
? t(
|
||||||
profile.seafarerDepartment
|
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
|
||||||
|
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||||
|
profile.seafarerDepartment,
|
||||||
|
)
|
||||||
: '—'}
|
: '—'}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Text size="xs" c="dimmed" tt="uppercase">
|
<Text size="xs" c="dimmed" tt="uppercase">
|
||||||
Status
|
{t('seafarerRegistry.drawer.status', 'Status')}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge
|
<Badge
|
||||||
color={
|
color={
|
||||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
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>
|
</Badge>
|
||||||
</div>
|
</div>
|
||||||
</Group>
|
</Group>
|
||||||
{profile.seafarerStatusReason && (
|
{profile.seafarerStatusReason && (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Status reason: {profile.seafarerStatusReason}
|
{t('seafarerRegistry.drawer.statusReason', {
|
||||||
|
reason: profile.seafarerStatusReason,
|
||||||
|
defaultValue: 'Status reason: {{reason}}',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||||
<Tabs.List>
|
<Tabs.List>
|
||||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||||
Sea Service
|
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||||
Medical
|
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
|
||||||
</Tabs.Tab>
|
</Tabs.Tab>
|
||||||
</Tabs.List>
|
</Tabs.List>
|
||||||
|
|
||||||
@@ -153,16 +164,16 @@ function SeafarerDetailDrawer({
|
|||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
) : (seaService ?? []).length === 0 ? (
|
) : (seaService ?? []).length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No sea-service records.
|
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table striped>
|
<Table striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Vessel</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
|
||||||
<Table.Th>Rank</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
|
||||||
<Table.Th>Period</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -172,17 +183,20 @@ function SeafarerDetailDrawer({
|
|||||||
{record.vesselName}
|
{record.vesselName}
|
||||||
{record.imoNumber && (
|
{record.imoNumber && (
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
IMO {record.imoNumber}
|
{t('seafarerRegistry.drawer.imoPrefix', {
|
||||||
|
number: record.imoNumber,
|
||||||
|
defaultValue: 'IMO {{number}}',
|
||||||
|
})}
|
||||||
</Text>
|
</Text>
|
||||||
)}
|
)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{record.rank}</Table.Td>
|
<Table.Td>{record.rank}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{record.engagementDate} → {record.dischargeDate}
|
{showDate(record.engagementDate)} → {showDate(record.dischargeDate)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||||
{record.status}
|
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -197,16 +211,16 @@ function SeafarerDetailDrawer({
|
|||||||
<Loader size="sm" />
|
<Loader size="sm" />
|
||||||
) : (medical ?? []).length === 0 ? (
|
) : (medical ?? []).length === 0 ? (
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
No medical certificates.
|
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
|
||||||
</Text>
|
</Text>
|
||||||
) : (
|
) : (
|
||||||
<Table striped>
|
<Table striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Issuer</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
|
||||||
<Table.Th>Validity</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
|
||||||
<Table.Th>Fitness</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
<Table.Tbody>
|
<Table.Tbody>
|
||||||
@@ -214,7 +228,7 @@ function SeafarerDetailDrawer({
|
|||||||
<Table.Tr key={certificate.id}>
|
<Table.Tr key={certificate.id}>
|
||||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
{certificate.issueDate} → {certificate.expiryDate}
|
{showDate(certificate.issueDate)} → {showDate(certificate.expiryDate)}
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -222,7 +236,7 @@ function SeafarerDetailDrawer({
|
|||||||
size="sm"
|
size="sm"
|
||||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||||
>
|
>
|
||||||
{certificate.status}
|
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
@@ -248,6 +262,7 @@ function StatusModal({
|
|||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onDone: () => void;
|
onDone: () => void;
|
||||||
}) {
|
}) {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [status, setStatus] = useState<string | null>(null);
|
const [status, setStatus] = useState<string | null>(null);
|
||||||
const [reason, setReason] = useState('');
|
const [reason, setReason] = useState('');
|
||||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||||
@@ -260,11 +275,13 @@ function StatusModal({
|
|||||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||||
reason,
|
reason,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
notify.success('Seafarer status updated');
|
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
|
||||||
onClose();
|
onClose();
|
||||||
onDone();
|
onDone();
|
||||||
} catch (error) {
|
} 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
|
<Modal
|
||||||
opened={Boolean(profile)}
|
opened={Boolean(profile)}
|
||||||
onClose={onClose}
|
onClose={onClose}
|
||||||
title="Change seafarer status"
|
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
|
||||||
centered
|
centered
|
||||||
>
|
>
|
||||||
<Stack>
|
<Stack>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
{t('seafarerRegistry.modal.body', {
|
||||||
reason is recorded and visible to the seafarer.
|
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>
|
</Text>
|
||||||
<Select
|
<Select
|
||||||
label="New status"
|
label={t('seafarerRegistry.modal.newStatus', 'New status')}
|
||||||
required
|
required
|
||||||
data={[
|
data={[
|
||||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
|
||||||
{ value: 'INACTIVE', label: 'Close' },
|
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
|
||||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
|
||||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||||
value={status}
|
value={status}
|
||||||
onChange={setStatus}
|
onChange={setStatus}
|
||||||
/>
|
/>
|
||||||
<Textarea
|
<Textarea
|
||||||
label="Reason"
|
label={t('seafarerRegistry.modal.reason', 'Reason')}
|
||||||
required
|
required
|
||||||
minRows={2}
|
minRows={2}
|
||||||
value={reason}
|
value={reason}
|
||||||
@@ -300,7 +323,7 @@ function StatusModal({
|
|||||||
/>
|
/>
|
||||||
<Group justify="flex-end">
|
<Group justify="flex-end">
|
||||||
<Button variant="default" onClick={onClose}>
|
<Button variant="default" onClick={onClose}>
|
||||||
Cancel
|
{t('seafarerRegistry.modal.cancel', 'Cancel')}
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||||
@@ -308,7 +331,7 @@ function StatusModal({
|
|||||||
loading={isLoading}
|
loading={isLoading}
|
||||||
onClick={submit}
|
onClick={submit}
|
||||||
>
|
>
|
||||||
Confirm
|
{t('seafarerRegistry.modal.confirm', 'Confirm')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Stack>
|
</Stack>
|
||||||
@@ -324,6 +347,7 @@ function StatusModal({
|
|||||||
* register — numbers, departments, statuses, and each seafarer's records.
|
* register — numbers, departments, statuses, and each seafarer's records.
|
||||||
*/
|
*/
|
||||||
export function SeafarerRegistryPage() {
|
export function SeafarerRegistryPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||||
@@ -335,6 +359,7 @@ export function SeafarerRegistryPage() {
|
|||||||
method: 'GET',
|
method: 'GET',
|
||||||
params: { q: 'i=profession,address&t=200' },
|
params: { q: 'i=profession,address&t=200' },
|
||||||
});
|
});
|
||||||
|
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||||
|
|
||||||
const items = (data?.items ?? []).filter((p) => {
|
const items = (data?.items ?? []).filter((p) => {
|
||||||
if (!search.trim()) return true;
|
if (!search.trim()) return true;
|
||||||
@@ -349,18 +374,95 @@ export function SeafarerRegistryPage() {
|
|||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.some((v) => String(v).toLowerCase().includes(term));
|
.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 (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
<Group justify="space-between" mb="md">
|
<Group justify="space-between" mb="md">
|
||||||
<div>
|
<div>
|
||||||
<Title order={3}>Seafarer registry</Title>
|
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
|
||||||
<Text size="sm" c="dimmed">
|
<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>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<TextInput
|
<TextInput
|
||||||
placeholder="Name, ID or seafarer number"
|
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
|
||||||
leftSection={<IconSearch size={14} />}
|
leftSection={<IconSearch size={14} />}
|
||||||
value={search}
|
value={search}
|
||||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||||
@@ -369,97 +471,23 @@ export function SeafarerRegistryPage() {
|
|||||||
</Group>
|
</Group>
|
||||||
|
|
||||||
<Card withBorder padding={0}>
|
<Card withBorder padding={0}>
|
||||||
{isLoading ? (
|
<AdvancedTable
|
||||||
<Center h={200}>
|
columns={columns}
|
||||||
<Loader />
|
data={page.rows}
|
||||||
</Center>
|
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
|
||||||
) : items.length === 0 ? (
|
itemCount={page.itemCount}
|
||||||
<Center h={160}>
|
pageIndex={page.pageIndex}
|
||||||
<Text size="sm" c="dimmed">
|
onPageChange={setPageIndex}
|
||||||
{search ? 'No profiles match that search.' : 'No seafarers registered yet.'}
|
pageSize={pageSize}
|
||||||
</Text>
|
onPageSizeChange={setPageSize}
|
||||||
</Center>
|
refresh={refetch}
|
||||||
) : (
|
isLoading={isLoading}
|
||||||
<Table highlightOnHover>
|
emptyText={
|
||||||
<Table.Thead>
|
search
|
||||||
<Table.Tr>
|
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
|
||||||
<Table.Th>Name</Table.Th>
|
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
<SeafarerDetailDrawer profile={detail} onClose={() => setDetail(null)} />
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import Cookies from 'js-cookie';
|
||||||
import { useCallback, useEffect, useRef } from 'react';
|
import { useCallback, useEffect, useRef } from 'react';
|
||||||
import { createRoot, type Root } from 'react-dom/client';
|
import { createRoot, type Root } from 'react-dom/client';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
@@ -138,8 +139,8 @@ export default function UserManagementPage() {
|
|||||||
style.textContent = UM_OVERRIDES;
|
style.textContent = UM_OVERRIDES;
|
||||||
document.head.appendChild(style);
|
document.head.appendChild(style);
|
||||||
|
|
||||||
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
|
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
|
||||||
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
|
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
|
||||||
|
|
||||||
const session: UserManagementSessionOptions = {
|
const session: UserManagementSessionOptions = {
|
||||||
initialSession: token
|
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,
|
IconSettings,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
|
|||||||
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
|
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
|
||||||
/>
|
/>
|
||||||
<Divider />
|
<Divider />
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
|
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
|
||||||
<Button color="teal" onClick={saveField}>
|
<Button color="teal" onClick={saveField}>
|
||||||
{isNew ? 'Add Field' : 'Save Changes'}
|
{isNew ? 'Add Field' : 'Save Changes'}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
|
|
||||||
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
|
|||||||
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
|
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
|
||||||
This cannot be undone.
|
This cannot be undone.
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end">
|
<ModalFooter>
|
||||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||||
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
|
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ import {
|
|||||||
IconShieldCog,
|
IconShieldCog,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
useGetVesselIncidentsQuery,
|
useGetVesselIncidentsQuery,
|
||||||
@@ -56,6 +57,7 @@ function VesselDetailDrawer({
|
|||||||
}) {
|
}) {
|
||||||
const { data: incidents, isLoading: loadingIncidents } =
|
const { data: incidents, isLoading: loadingIncidents } =
|
||||||
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
|
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
const particulars: [string, string | number | null][] = vessel
|
const particulars: [string, string | number | null][] = vessel
|
||||||
? [
|
? [
|
||||||
@@ -75,7 +77,7 @@ function VesselDetailDrawer({
|
|||||||
['Engines', vessel.numberOfEngines],
|
['Engines', vessel.numberOfEngines],
|
||||||
['Hull material', vessel.hullMaterial],
|
['Hull material', vessel.hullMaterial],
|
||||||
['Owner', vessel.ownerName],
|
['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">
|
<Card key={incident.id} withBorder radius="md" p="sm">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" fw={600}>
|
<Text size="sm" fw={600}>
|
||||||
{incident.occurredAt}
|
{showDate(incident.occurredAt)}
|
||||||
{incident.location ? ` — ${incident.location}` : ''}
|
{incident.location ? ` — ${incident.location}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
<Badge size="sm" variant="light">
|
<Badge size="sm" variant="light">
|
||||||
@@ -240,6 +242,7 @@ export function VesselRegistrationQueuePage() {
|
|||||||
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
|
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
const items = data?.items ?? [];
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Container size="xl" py="md">
|
<Container size="xl" py="md">
|
||||||
@@ -319,7 +322,7 @@ export function VesselRegistrationQueuePage() {
|
|||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
{vessel.registeredAt?.slice(0, 10)}
|
{showDate(vessel.registeredAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@ export const en = {
|
|||||||
tagline: 'Control Center',
|
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: {
|
language: {
|
||||||
label: 'Language',
|
label: 'Language',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
@@ -23,6 +34,12 @@ export const en = {
|
|||||||
typeCombined: 'Combined SA + FF',
|
typeCombined: 'Combined SA + FF',
|
||||||
typeJointInvestment: 'Joint Investment',
|
typeJointInvestment: 'Joint Investment',
|
||||||
typeMto: 'Multimodal Transport Operator',
|
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',
|
primary: 'Primary',
|
||||||
destinations: 'Go to',
|
destinations: 'Go to',
|
||||||
noResults: 'Nothing found',
|
noResults: 'Nothing found',
|
||||||
@@ -43,6 +60,7 @@ export const en = {
|
|||||||
userManagement: 'User Management',
|
userManagement: 'User Management',
|
||||||
seamanBookQueue: 'Seaman Book Queue',
|
seamanBookQueue: 'Seaman Book Queue',
|
||||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||||
|
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||||
vesselRegistrationQueue: 'Vessel Register',
|
vesselRegistrationQueue: 'Vessel Register',
|
||||||
vesselFormBuilder: 'Vessel Form Builder',
|
vesselFormBuilder: 'Vessel Form Builder',
|
||||||
vesselRegistrationReport: 'Vessel Registration Report',
|
vesselRegistrationReport: 'Vessel Registration Report',
|
||||||
@@ -56,13 +74,18 @@ export const en = {
|
|||||||
waiver: 'Waiver',
|
waiver: 'Waiver',
|
||||||
preWaiverQueue: 'Pre-Waiver Queue',
|
preWaiverQueue: 'Pre-Waiver Queue',
|
||||||
postWaiverQueue: 'Post-Waiver Queue',
|
postWaiverQueue: 'Post-Waiver Queue',
|
||||||
cocQueue: 'CoC / CoP Queue',
|
cocQueue: 'CoC Queue',
|
||||||
endorsementQueue: 'Endorsement Queue',
|
copQueue: 'CoP Queue',
|
||||||
|
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||||
|
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||||
|
vesselRegistrations: 'Vessel Registration',
|
||||||
|
vesselTransfers: 'Vessel Ownership Transfer',
|
||||||
seafarerRegistry: 'Seafarer Registry',
|
seafarerRegistry: 'Seafarer Registry',
|
||||||
|
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||||
applications: 'Applications',
|
applications: 'Applications',
|
||||||
paymentConfig: 'Payment Config',
|
paymentConfig: 'Payment Config',
|
||||||
analytics: 'Analytics',
|
analytics: 'Analytics',
|
||||||
medicalVerification: 'Medical Verification',
|
medicalVerification: 'Medical and Sea Service Verification',
|
||||||
locations: 'Locations',
|
locations: 'Locations',
|
||||||
configuration: 'Configuration',
|
configuration: 'Configuration',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
@@ -86,6 +109,12 @@ export const en = {
|
|||||||
collapse: 'Collapse',
|
collapse: 'Collapse',
|
||||||
expand: 'Expand',
|
expand: 'Expand',
|
||||||
toggleTheme: 'Toggle light / dark mode',
|
toggleTheme: 'Toggle light / dark mode',
|
||||||
|
refresh: 'Refresh',
|
||||||
|
view: 'View',
|
||||||
|
toggleColumns: 'Toggle columns',
|
||||||
|
noResult: 'No results',
|
||||||
|
switchCalendar: 'Switch calendar type',
|
||||||
|
time: 'Time',
|
||||||
},
|
},
|
||||||
|
|
||||||
breadcrumbs: {
|
breadcrumbs: {
|
||||||
@@ -332,6 +361,11 @@ export const en = {
|
|||||||
'Not enough approved questions in the bank for this subject.',
|
'Not enough approved questions in the bank for this subject.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
country: {
|
||||||
|
select: 'Select a country',
|
||||||
|
notFound: 'No countries found',
|
||||||
|
},
|
||||||
|
|
||||||
location: {
|
location: {
|
||||||
title: 'Locations',
|
title: 'Locations',
|
||||||
hierarchy: 'Location Hierarchy',
|
hierarchy: 'Location Hierarchy',
|
||||||
@@ -753,6 +787,23 @@ export const en = {
|
|||||||
anyType: 'Any',
|
anyType: 'Any',
|
||||||
typeCol: 'Type',
|
typeCol: 'Type',
|
||||||
statusCol: 'Status',
|
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',
|
submittedFrom: 'Submitted from',
|
||||||
submittedTo: 'Submitted to',
|
submittedTo: 'Submitted to',
|
||||||
clearFilters: 'Clear',
|
clearFilters: 'Clear',
|
||||||
@@ -765,6 +816,7 @@ export const en = {
|
|||||||
compact: 'Compact',
|
compact: 'Compact',
|
||||||
number: 'App #',
|
number: 'App #',
|
||||||
company: 'Company',
|
company: 'Company',
|
||||||
|
applicant: 'Applicant',
|
||||||
tin: 'TIN',
|
tin: 'TIN',
|
||||||
submitted: 'Submitted',
|
submitted: 'Submitted',
|
||||||
sla: 'Age / SLA',
|
sla: 'Age / SLA',
|
||||||
@@ -960,6 +1012,42 @@ export const en = {
|
|||||||
noFile: 'No file',
|
noFile: 'No file',
|
||||||
noFileUploaded: 'Nothing uploaded yet',
|
noFileUploaded: 'Nothing uploaded yet',
|
||||||
noInlinePreview: 'This file type cannot be previewed in the browser.',
|
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: {
|
done: {
|
||||||
completeReview: 'Review completed',
|
completeReview: 'Review completed',
|
||||||
@@ -1031,6 +1119,162 @@ export const en = {
|
|||||||
noPermission: 'You do not have permission',
|
noPermission: 'You do not have permission',
|
||||||
noPublishPermission: 'You cannot publish designs',
|
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;
|
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 type { NavItem, NavSection } from '@ema-platform/ui';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
import { AppTopNav, filterByPermissions } 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 { usePermissions } from '@ema-platform/auth';
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||||
@@ -76,12 +76,18 @@ export function BackofficeLayout() {
|
|||||||
|
|
||||||
const displayName = user?.name?.en || user?.username || '';
|
const displayName = user?.name?.en || user?.username || '';
|
||||||
const initials = displayName
|
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(() => {
|
const handleLogout = useCallback(() => {
|
||||||
dispatch(logout());
|
dispatch(logout());
|
||||||
navigate('/login');
|
dispatch(baseApi.util.resetApiState());
|
||||||
|
navigate("/login");
|
||||||
}, [dispatch, navigate]);
|
}, [dispatch, navigate]);
|
||||||
|
|
||||||
const segments = location.pathname.split('/').filter(Boolean);
|
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
|
// readable form of the path segment. Every crumb was previously labelled
|
||||||
// "Dashboard", which made the trail useless.
|
// "Dashboard", which made the trail useless.
|
||||||
const crumbs = [
|
const crumbs = [
|
||||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||||
...segments
|
...segments
|
||||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||||
.filter((path) => path !== '/dashboard')
|
.filter((path) => path !== '/dashboard')
|
||||||
@@ -123,27 +129,31 @@ export function BackofficeLayout() {
|
|||||||
setCollapsed((prev) => !prev);
|
setCollapsed((prev) => !prev);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const isSidebar = layoutMode === 'sidebar';
|
const isSidebar = layoutMode === "sidebar";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
||||||
navbar={isSidebar ? {
|
navbar={
|
||||||
width: collapsed ? 72 : 264,
|
isSidebar
|
||||||
breakpoint: 'sm',
|
? {
|
||||||
collapsed: { mobile: !opened },
|
width: collapsed ? 72 : 264,
|
||||||
} : undefined}
|
breakpoint: "sm",
|
||||||
|
collapsed: { mobile: !opened },
|
||||||
|
}
|
||||||
|
: undefined
|
||||||
|
}
|
||||||
padding="lg"
|
padding="lg"
|
||||||
>
|
>
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--mantine-color-body)',
|
background: "var(--mantine-color-body)",
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
display: 'flex',
|
display: "flex",
|
||||||
flexDirection: 'column',
|
flexDirection: "column",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
onToggleNav={toggleNav}
|
onToggleNav={toggleNav}
|
||||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||||
@@ -151,7 +161,7 @@ export function BackofficeLayout() {
|
|||||||
breadcrumbs={crumbs}
|
breadcrumbs={crumbs}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
userName={displayName || t('app.name')}
|
userName={displayName || t("app.name")}
|
||||||
userInitials={initials}
|
userInitials={initials}
|
||||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||||
/>
|
/>
|
||||||
@@ -183,10 +193,10 @@ export function BackofficeLayout() {
|
|||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
p={0}
|
p={0}
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
transition: 'width 200ms ease',
|
transition: "width 200ms ease",
|
||||||
background: 'var(--mantine-color-body)',
|
background: "var(--mantine-color-body)",
|
||||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import {
|
import {
|
||||||
IconAnchor,
|
IconAnchor,
|
||||||
|
IconArrowsExchange,
|
||||||
IconBook2,
|
IconBook2,
|
||||||
IconChartBar,
|
IconChartBar,
|
||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
@@ -9,9 +10,9 @@ import {
|
|||||||
IconGauge,
|
IconGauge,
|
||||||
IconGavel,
|
IconGavel,
|
||||||
IconHeart,
|
IconHeart,
|
||||||
|
IconId,
|
||||||
IconLayoutDashboard,
|
IconLayoutDashboard,
|
||||||
IconListCheck,
|
IconListCheck,
|
||||||
IconMapPin,
|
|
||||||
IconQuestionMark,
|
IconQuestionMark,
|
||||||
IconReport,
|
IconReport,
|
||||||
IconRosetteDiscountCheck,
|
IconRosetteDiscountCheck,
|
||||||
@@ -109,17 +110,21 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
label: 'nav.groupSeafarer',
|
label: 'nav.groupSeafarer',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
{ 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_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: '/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 },
|
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupVessels',
|
label: 'nav.groupVessels',
|
||||||
items: [
|
items: [
|
||||||
|
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor },
|
||||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', 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-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, 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 },
|
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||||
@@ -138,7 +143,6 @@ export const NAV_SECTIONS: NavSection[] = [
|
|||||||
label: 'nav.groupAdministration',
|
label: 'nav.groupAdministration',
|
||||||
items: [
|
items: [
|
||||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
|
||||||
{
|
{
|
||||||
to: '/configuration',
|
to: '/configuration',
|
||||||
label: 'nav.configuration',
|
label: 'nav.configuration',
|
||||||
|
|||||||
@@ -16,11 +16,8 @@ import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
|||||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
|
||||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
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 { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
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 { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||||
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
|
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 { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||||
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
||||||
@@ -69,14 +64,14 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
||||||
{ path: 'profile', element: <ProfilePage /> },
|
{ path: 'profile', element: <ProfilePage /> },
|
||||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||||
{ path: 'locations', element: <LocationPage /> },
|
|
||||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
||||||
// CoC/CoP review happens in the config-driven licence queue.
|
// CoC/CoP review happens in the config-driven licence queue.
|
||||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
|
// Endorsement review happens in the config-driven licence queue.
|
||||||
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
|
{ 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: 'medical-verification', element: <MedicalVerificationPage /> },
|
||||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||||
@@ -88,10 +83,10 @@ const router = createBrowserRouter([
|
|||||||
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
||||||
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
||||||
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
||||||
{ path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
||||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||||
// Config-driven review workspace, shared by every licence type.
|
// Config-driven review workspace, shared by every licence type.
|
||||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||||
|
|||||||
@@ -7,11 +7,12 @@ import {
|
|||||||
authStorage,
|
authStorage,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
|
setToken,
|
||||||
} from '@ema-platform/auth';
|
} from '@ema-platform/auth';
|
||||||
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||||
import { preferencesReducer } from './preferences.slice';
|
import { preferencesReducer } from './preferences.slice';
|
||||||
|
|
||||||
configureAuthStorage('ema-backoffice');
|
configureAuthStorage('ema-backoffice', true);
|
||||||
|
|
||||||
const preloadedAuth = (() => {
|
const preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
@@ -36,7 +37,11 @@ export const store = configureStore({
|
|||||||
});
|
});
|
||||||
|
|
||||||
configureTokenRefresh({
|
configureTokenRefresh({
|
||||||
onTokenExpired: refreshAccessToken,
|
onTokenExpired: async () => {
|
||||||
|
const token = await refreshAccessToken();
|
||||||
|
store.dispatch(setToken(token));
|
||||||
|
return token;
|
||||||
|
},
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
store.dispatch(logout());
|
store.dispatch(logout());
|
||||||
window.location.href = '/login';
|
window.location.href = '/login';
|
||||||
|
|||||||
@@ -5,6 +5,44 @@
|
|||||||
|
|
||||||
*, *::before, *::after { box-sizing: border-box; }
|
*, *::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.
|
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,
|
STATUS_LABELS,
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
useLocalized,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
@@ -33,6 +34,7 @@ import {
|
|||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { useCurrentProfile } from '@ema-platform/auth';
|
import { useCurrentProfile } from '@ema-platform/auth';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
const CERTIFICATE_TYPE_KEYS = [
|
const CERTIFICATE_TYPE_KEYS = [
|
||||||
'CERTIFICATE_OF_COMPETENCY',
|
'CERTIFICATE_OF_COMPETENCY',
|
||||||
@@ -72,6 +74,8 @@ export function CertificatesPage() {
|
|||||||
useGetMyApplicationsQuery();
|
useGetMyApplicationsQuery();
|
||||||
const { data: licenses } = useGetMyLicensesQuery();
|
const { data: licenses } = useGetMyLicensesQuery();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
const registered =
|
const registered =
|
||||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||||
@@ -187,7 +191,7 @@ export function CertificatesPage() {
|
|||||||
<div>
|
<div>
|
||||||
<Text fw={600}>{app.applicationNumber}</Text>
|
<Text fw={600}>{app.applicationNumber}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{app.licenseType?.name?.en}
|
{localized(app.licenseType?.name)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group>
|
<Group>
|
||||||
@@ -243,9 +247,9 @@ export function CertificatesPage() {
|
|||||||
{license.certificateNumber}
|
{license.certificateNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{license.licenseType?.name?.en}</Table.Td>
|
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
||||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||||
<Table.Td>{license.expiryDate?.slice(0, 10)}</Table.Td>
|
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="sm"
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ import { useMemo } from 'react';
|
|||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { useSelector } from 'react-redux';
|
import { useSelector } from 'react-redux';
|
||||||
import {
|
import {
|
||||||
ActionIcon,
|
|
||||||
Alert,
|
Alert,
|
||||||
Anchor,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
@@ -11,7 +10,6 @@ import {
|
|||||||
Card,
|
Card,
|
||||||
Center,
|
Center,
|
||||||
Container,
|
Container,
|
||||||
Divider,
|
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
Paper,
|
Paper,
|
||||||
@@ -22,7 +20,6 @@ import {
|
|||||||
Text,
|
Text,
|
||||||
ThemeIcon,
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
Tooltip,
|
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
IconAlertTriangle,
|
IconAlertTriangle,
|
||||||
@@ -30,9 +27,7 @@ import {
|
|||||||
IconClipboardList,
|
IconClipboardList,
|
||||||
IconClockHour4,
|
IconClockHour4,
|
||||||
IconCreditCard,
|
IconCreditCard,
|
||||||
IconDownload,
|
|
||||||
IconFileText,
|
IconFileText,
|
||||||
IconRefresh,
|
|
||||||
IconShieldCheck,
|
IconShieldCheck,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
import { ProfileCompletionNudge } from '../../profile/components/ProfileCompletionNudge';
|
||||||
@@ -42,16 +37,14 @@ import {
|
|||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
STATUS_PROGRESS,
|
STATUS_PROGRESS,
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
extractErrorMessage,
|
useLocalized,
|
||||||
localized,
|
|
||||||
useCreateApplicationMutation,
|
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
|
||||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||||
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
import { LicenseCatalogue } from '../../licensing/components/LicenseCatalogue';
|
||||||
|
import { LicenseCard, useRenewLicense } from '../../licensing/components/LicenseCard';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The applicant's home screen.
|
* The applicant's home screen.
|
||||||
@@ -82,14 +75,6 @@ function formatMoney(amount: string | number | null, currency: string): string {
|
|||||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
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() {
|
export function DashboardPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const displayName = useSelector(
|
const displayName = useSelector(
|
||||||
@@ -101,8 +86,7 @@ export function DashboardPage() {
|
|||||||
const { data: licenses } = useGetMyLicensesQuery();
|
const { data: licenses } = useGetMyLicensesQuery();
|
||||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
const [getCertificateUrl, { isLoading: isDownloading }] =
|
||||||
useGetCertificateUrlMutation();
|
useGetCertificateUrlMutation();
|
||||||
const [createApplication, { isLoading: isRenewing }] =
|
const { renewLicense, isRenewing } = useRenewLicense();
|
||||||
useCreateApplicationMutation();
|
|
||||||
|
|
||||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||||
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
||||||
@@ -127,27 +111,6 @@ export function DashboardPage() {
|
|||||||
window.open(result.url, '_blank', 'noopener');
|
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) {
|
if (isLoading) {
|
||||||
return (
|
return (
|
||||||
<Center h={400}>
|
<Center h={400}>
|
||||||
@@ -321,7 +284,12 @@ function ActionRequired({
|
|||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
return (
|
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">
|
<Group gap="xs" mb="sm">
|
||||||
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
||||||
<IconAlertTriangle size={14} />
|
<IconAlertTriangle size={14} />
|
||||||
@@ -334,7 +302,7 @@ function ActionRequired({
|
|||||||
{applications.map((app) => {
|
{applications.map((app) => {
|
||||||
const detail = detailFor(app);
|
const detail = detailFor(app);
|
||||||
return (
|
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 justify="space-between" wrap="nowrap">
|
||||||
<Group gap="sm" wrap="nowrap">
|
<Group gap="sm" wrap="nowrap">
|
||||||
<ThemeIcon
|
<ThemeIcon
|
||||||
@@ -489,6 +457,7 @@ function ApplicationTable({
|
|||||||
applications: LicenseApplication[];
|
applications: LicenseApplication[];
|
||||||
navigate: (path: string) => void;
|
navigate: (path: string) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const localized = useLocalized();
|
||||||
return (
|
return (
|
||||||
<Card withBorder radius="md" padding={0}>
|
<Card withBorder radius="md" padding={0}>
|
||||||
<Table.ScrollContainer minWidth={640}>
|
<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.
|
* 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
|
* It carries no call to action of its own — the catalogue is directly beneath
|
||||||
|
|||||||
@@ -1,20 +1,254 @@
|
|||||||
import { Container } from '@mantine/core';
|
import {
|
||||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
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.
|
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
|
||||||
*
|
* two application entry points (CoC / GOC), and the seafarer's endorsement
|
||||||
* This page previously rendered hardcoded sample records, which were
|
* applications and issued endorsements. The wizard itself is the
|
||||||
* indistinguishable from real ones.
|
* config-driven licensing flow.
|
||||||
*/
|
*/
|
||||||
export function EndorsementPage() {
|
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 (
|
return (
|
||||||
<Container size="lg" py="xl">
|
<Stack maw={860} mx="auto">
|
||||||
<FeatureUnavailable
|
<Title order={2}>My Endorsements</Title>
|
||||||
title="Endorsements"
|
|
||||||
description="Endorsements are not connected to the backend yet."
|
<Card withBorder radius="md" p="lg">
|
||||||
/>
|
<Group justify="space-between" align="flex-start">
|
||||||
</Container>
|
<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';
|
} from '@mantine/core';
|
||||||
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
|
import { IconClipboardList, IconFileText, IconGavel } from '@tabler/icons-react';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
import {
|
import {
|
||||||
useApiQuery,
|
useApiQuery,
|
||||||
useApiMutation,
|
useApiMutation,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
openAuthedDocument,
|
openAuthedDocument,
|
||||||
|
useLocalized,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
interface OpenExam {
|
interface OpenExam {
|
||||||
@@ -81,6 +83,8 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
|||||||
* when a mark looks wrong.
|
* when a mark looks wrong.
|
||||||
*/
|
*/
|
||||||
export function ExamsPage() {
|
export function ExamsPage() {
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||||
const [appealReason, setAppealReason] = useState('');
|
const [appealReason, setAppealReason] = useState('');
|
||||||
|
|
||||||
@@ -195,10 +199,10 @@ export function ExamsPage() {
|
|||||||
<Card key={exam.id} withBorder radius="md" p="md">
|
<Card key={exam.id} withBorder radius="md" p="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600}>{exam.title?.en}</Text>
|
<Text fw={600}>{localized(exam.title)}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{exam.certification?.name?.en ?? ''} ·{' '}
|
{localized(exam.certification?.name)} ·{' '}
|
||||||
{exam.date?.slice(0, 10)}
|
{showDate(exam.date)}
|
||||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
@@ -252,8 +256,8 @@ export function ExamsPage() {
|
|||||||
{registration.admissionNumber}
|
{registration.admissionNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{registration.exam?.title?.en ?? '—'}</Table.Td>
|
<Table.Td>{localized(registration.exam?.title) || '—'}</Table.Td>
|
||||||
<Table.Td>{registration.exam?.date?.slice(0, 10)}</Table.Td>
|
<Table.Td>{showDate(registration.exam?.date)}</Table.Td>
|
||||||
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
|
<Table.Td>{registration.exam?.venue ?? '—'}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
@@ -324,8 +328,8 @@ export function ExamsPage() {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<Table.Tr key={result.id}>
|
<Table.Tr key={result.id}>
|
||||||
<Table.Td>{result.exam?.title?.en ?? '—'}</Table.Td>
|
<Table.Td>{localized(result.exam?.title) || '—'}</Table.Td>
|
||||||
<Table.Td>{result.publishedAt?.slice(0, 10) ?? '—'}</Table.Td>
|
<Table.Td>{showDate(result.publishedAt)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Text fw={600} size="sm">
|
<Text fw={600} size="sm">
|
||||||
{result.totalScore}
|
{result.totalScore}
|
||||||
@@ -374,7 +378,7 @@ export function ExamsPage() {
|
|||||||
<Stack>
|
<Stack>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Explain what you believe went wrong with the marking or the
|
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.
|
Appeals must be lodged within 14 days of publication.
|
||||||
</Text>
|
</Text>
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -8,9 +8,12 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import {
|
import {
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
localized,
|
useLocalized,
|
||||||
|
type FormFieldConfig,
|
||||||
type FormSectionConfig,
|
type FormSectionConfig,
|
||||||
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
section: FormSectionConfig;
|
section: FormSectionConfig;
|
||||||
@@ -20,6 +23,60 @@ interface Props {
|
|||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
|
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
|
||||||
errors?: Record<string, string>;
|
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,
|
onChange,
|
||||||
disabled,
|
disabled,
|
||||||
errors = {},
|
errors = {},
|
||||||
|
vessels = [],
|
||||||
|
onVesselSelected,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const localized = useLocalized();
|
||||||
const fields = [...(section.fields ?? [])].sort(
|
const fields = [...(section.fields ?? [])].sort(
|
||||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||||
);
|
);
|
||||||
@@ -47,6 +107,8 @@ export function ConfigDrivenSection({
|
|||||||
if (!conditionHolds(field.showWhen, formData)) return null;
|
if (!conditionHolds(field.showWhen, formData)) return null;
|
||||||
|
|
||||||
const label = localized(field.label);
|
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 value = values?.[field.key];
|
||||||
const error = errors[`${section.key}.${field.key}`];
|
const error = errors[`${section.key}.${field.key}`];
|
||||||
const common = {
|
const common = {
|
||||||
@@ -58,10 +120,41 @@ export function ConfigDrivenSection({
|
|||||||
disabled: disabled || field.readOnly,
|
disabled: disabled || field.readOnly,
|
||||||
};
|
};
|
||||||
const span = field.type === 'TEXTAREA' ? 12 : 6;
|
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 (
|
return (
|
||||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
<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
|
<Select
|
||||||
{...common}
|
{...common}
|
||||||
data={(field.options ?? []).map((o) => ({
|
data={(field.options ?? []).map((o) => ({
|
||||||
@@ -93,11 +186,16 @@ export function ConfigDrivenSection({
|
|||||||
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
|
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
|
||||||
/>
|
/>
|
||||||
) : field.type === 'DATE' ? (
|
) : field.type === 'DATE' ? (
|
||||||
<TextInput
|
// AmharicDatePicker has no `description`/`withAsterisk` props (from
|
||||||
{...common}
|
// `common`) — pass `required` explicitly so the asterisk still shows.
|
||||||
type="date"
|
<AmharicDatePicker
|
||||||
|
label={label}
|
||||||
|
error={error}
|
||||||
|
disabled={common.disabled}
|
||||||
|
required={field.required}
|
||||||
|
dateFormat="date"
|
||||||
value={(value as string) ?? ''}
|
value={(value as string) ?? ''}
|
||||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
onChange={(v) => onChange(field.key, v)}
|
||||||
/>
|
/>
|
||||||
) : field.type === 'TEXTAREA' ? (
|
) : field.type === 'TEXTAREA' ? (
|
||||||
<Textarea
|
<Textarea
|
||||||
|
|||||||
@@ -19,12 +19,14 @@ import {
|
|||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
localized,
|
useLocalized,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
type Attachment,
|
type Attachment,
|
||||||
type DocumentRequirement,
|
type DocumentRequirement,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
requirements: DocumentRequirement[];
|
requirements: DocumentRequirement[];
|
||||||
attachments: Attachment[];
|
attachments: Attachment[];
|
||||||
@@ -56,6 +58,7 @@ export function DocumentSlots({
|
|||||||
onUploaded,
|
onUploaded,
|
||||||
readOnly,
|
readOnly,
|
||||||
}: Props) {
|
}: Props) {
|
||||||
|
const localized = useLocalized();
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const resetRefs = useRef<Record<string, () => void>>({});
|
const resetRefs = useRef<Record<string, () => void>>({});
|
||||||
@@ -63,11 +66,17 @@ export function DocumentSlots({
|
|||||||
const required = requirements.filter(
|
const required = requirements.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
r.mode === 'ALWAYS' ||
|
r.mode === 'ALWAYS' ||
|
||||||
|
r.mode === 'OPTIONAL' ||
|
||||||
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
|
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
|
||||||
);
|
);
|
||||||
|
|
||||||
async function handle(documentKey: string, file: File | null) {
|
async function handle(documentKey: string, file: File | null) {
|
||||||
if (!file) return;
|
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);
|
setBusy(documentKey);
|
||||||
setError(null);
|
setError(null);
|
||||||
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
|
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
|
||||||
@@ -92,7 +101,7 @@ export function DocumentSlots({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<Card
|
<Card
|
||||||
key={requirement.key}
|
key={requirement.id}
|
||||||
withBorder
|
withBorder
|
||||||
padding="md"
|
padding="md"
|
||||||
style={{
|
style={{
|
||||||
@@ -115,6 +124,11 @@ export function DocumentSlots({
|
|||||||
conditional
|
conditional
|
||||||
</Badge>
|
</Badge>
|
||||||
)}
|
)}
|
||||||
|
{requirement.mode === 'OPTIONAL' && (
|
||||||
|
<Badge size="xs" variant="light" color="gray">
|
||||||
|
optional
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
{uploaded && !flagRemark && (
|
{uploaded && !flagRemark && (
|
||||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||||
uploaded
|
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,
|
IconTrendingUp,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
localized,
|
useLocalized,
|
||||||
useGetLicenseCategoriesQuery,
|
useGetLicenseCategoriesQuery,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetMyOperatorTypesQuery,
|
useGetMyOperatorTypesQuery,
|
||||||
@@ -55,6 +55,7 @@ function formatFee(amount: string | number | null, currency: string): string {
|
|||||||
|
|
||||||
export function LicenseCatalogue() {
|
export function LicenseCatalogue() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const localized = useLocalized();
|
||||||
const { data: types } = useGetLicenseTypesQuery();
|
const { data: types } = useGetLicenseTypesQuery();
|
||||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||||
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||||
@@ -253,6 +254,7 @@ function LicenseTypeCard({
|
|||||||
canApply: boolean;
|
canApply: boolean;
|
||||||
onSelect: (type: LicenseType) => void;
|
onSelect: (type: LicenseType) => void;
|
||||||
}) {
|
}) {
|
||||||
|
const localized = useLocalized();
|
||||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,13 +1,16 @@
|
|||||||
import { useEffect, useState } from 'react';
|
import { useEffect, useState } from 'react';
|
||||||
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||||
|
import { notifications } from '@mantine/notifications';
|
||||||
import { IconCheck } from '@tabler/icons-react';
|
import { IconCheck } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
localized,
|
useLocalized,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
useGetAttachmentsQuery,
|
useGetAttachmentsQuery,
|
||||||
type StaffEvidenceRequirement,
|
type StaffEvidenceRequirement,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
|
|
||||||
|
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
staffId: string;
|
staffId: string;
|
||||||
evidence: StaffEvidenceRequirement[];
|
evidence: StaffEvidenceRequirement[];
|
||||||
@@ -27,6 +30,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
|||||||
ownerId: staffId,
|
ownerId: staffId,
|
||||||
});
|
});
|
||||||
const [busy, setBusy] = useState<string | null>(null);
|
const [busy, setBusy] = useState<string | null>(null);
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
if (!evidence?.length) return null;
|
if (!evidence?.length) return null;
|
||||||
|
|
||||||
@@ -42,6 +46,13 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
|||||||
accept="application/pdf,image/jpeg,image/png"
|
accept="application/pdf,image/jpeg,image/png"
|
||||||
onChange={async (file) => {
|
onChange={async (file) => {
|
||||||
if (!file) return;
|
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);
|
setBusy(item.docKey);
|
||||||
await uploadDocument({
|
await uploadDocument({
|
||||||
ownerType: 'APPLICATION_STAFF',
|
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,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
buildWizardSteps,
|
buildWizardSteps,
|
||||||
conditionHolds,
|
conditionHolds,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
extractValidationIssues,
|
extractValidationIssues,
|
||||||
localized,
|
useLocalized,
|
||||||
validateSections,
|
validateSections,
|
||||||
useAddStaffMutation,
|
useAddStaffMutation,
|
||||||
useCreateApplicationMutation,
|
useCreateApplicationMutation,
|
||||||
useGetApplicationQuery,
|
useGetApplicationQuery,
|
||||||
useGetAttachmentsQuery,
|
useGetAttachmentsQuery,
|
||||||
useGetLicenseTypeRequirementsQuery,
|
useGetLicenseTypeRequirementsQuery,
|
||||||
|
useGetMyVesselsQuery,
|
||||||
usePatchSectionMutation,
|
usePatchSectionMutation,
|
||||||
useRemoveStaffMutation,
|
useRemoveStaffMutation,
|
||||||
useResolveRemarkMutation,
|
useResolveRemarkMutation,
|
||||||
useResubmitApplicationMutation,
|
useResubmitApplicationMutation,
|
||||||
useSubmitApplicationMutation,
|
useSubmitApplicationMutation,
|
||||||
type FieldErrors,
|
type FieldErrors,
|
||||||
|
type FormFieldConfig,
|
||||||
type ValidationIssue,
|
type ValidationIssue,
|
||||||
|
type Vessel,
|
||||||
} from '@ema-platform/api';
|
} 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 { DocumentSlots } from '../components/DocumentSlots';
|
||||||
import { StaffEvidence } from '../components/StaffEvidence';
|
import { StaffEvidence } from '../components/StaffEvidence';
|
||||||
|
|
||||||
@@ -61,9 +67,15 @@ import { StaffEvidence } from '../components/StaffEvidence';
|
|||||||
export function LicenseApplicationPage() {
|
export function LicenseApplicationPage() {
|
||||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const { i18n } = useTranslation();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
const { data: config, isLoading: loadingConfig } =
|
const { data: config, isLoading: loadingConfig } =
|
||||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
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 [createApplication] = useCreateApplicationMutation();
|
||||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||||
|
|
||||||
@@ -111,6 +123,55 @@ export function LicenseApplicationPage() {
|
|||||||
if (detail?.application?.formData) setDraft(detail.application.formData);
|
if (detail?.application?.formData) setDraft(detail.application.formData);
|
||||||
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
}, [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 application = detail?.application;
|
||||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||||
const openRemarks = detail?.openRemarks ?? [];
|
const openRemarks = detail?.openRemarks ?? [];
|
||||||
@@ -136,8 +197,9 @@ export function LicenseApplicationPage() {
|
|||||||
() =>
|
() =>
|
||||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||||
|
language: i18n.language,
|
||||||
}),
|
}),
|
||||||
[config, draft],
|
[config, draft, i18n.language],
|
||||||
);
|
);
|
||||||
const sections = useMemo(
|
const sections = useMemo(
|
||||||
() => steps.flatMap((step) => step.sections),
|
() => steps.flatMap((step) => step.sections),
|
||||||
@@ -154,15 +216,39 @@ export function LicenseApplicationPage() {
|
|||||||
|
|
||||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
|
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) {
|
async function saveSection(sectionKey: string) {
|
||||||
// During an adjustment round only flagged sections are editable, so don't
|
// During an adjustment round only flagged sections are editable, so don't
|
||||||
// even attempt a write the server would reject.
|
// even attempt a write the server would reject.
|
||||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
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 {
|
try {
|
||||||
await patchSection({
|
await patchSection({
|
||||||
id: appId as string,
|
id: appId as string,
|
||||||
sectionKey,
|
sectionKey,
|
||||||
values: draft[sectionKey] ?? {},
|
values,
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -176,7 +262,7 @@ export function LicenseApplicationPage() {
|
|||||||
async function handleSubmit() {
|
async function handleSubmit() {
|
||||||
setIssues([]);
|
setIssues([]);
|
||||||
if (!readOnly && currentStep?.sections?.length) {
|
if (!readOnly && currentStep?.sections?.length) {
|
||||||
const errors = validateSections(currentStep.sections, draft);
|
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
if (Object.keys(errors).length) {
|
if (Object.keys(errors).length) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
@@ -236,7 +322,7 @@ export function LicenseApplicationPage() {
|
|||||||
if (!currentStep || !config) return true;
|
if (!currentStep || !config) return true;
|
||||||
|
|
||||||
if (currentStep.kind === 'sections') {
|
if (currentStep.kind === 'sections') {
|
||||||
const errors = validateSections(currentStep.sections, draft);
|
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||||
setFieldErrors(errors);
|
setFieldErrors(errors);
|
||||||
const count = Object.keys(errors).length;
|
const count = Object.keys(errors).length;
|
||||||
if (count > 0) {
|
if (count > 0) {
|
||||||
@@ -394,7 +480,9 @@ export function LicenseApplicationPage() {
|
|||||||
section={section}
|
section={section}
|
||||||
values={draft[section.key] ?? {}}
|
values={draft[section.key] ?? {}}
|
||||||
formData={draft}
|
formData={draft}
|
||||||
|
onVesselSelected={handleVesselSelected}
|
||||||
errors={fieldErrors}
|
errors={fieldErrors}
|
||||||
|
vessels={vessels}
|
||||||
disabled={readOnly || locked}
|
disabled={readOnly || locked}
|
||||||
onChange={(key, value) => {
|
onChange={(key, value) => {
|
||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
@@ -523,9 +611,11 @@ export function LicenseApplicationPage() {
|
|||||||
</Text>
|
</Text>
|
||||||
<ConfigDrivenSection
|
<ConfigDrivenSection
|
||||||
section={section}
|
section={section}
|
||||||
|
onVesselSelected={handleVesselSelected}
|
||||||
values={draft[section.key] ?? {}}
|
values={draft[section.key] ?? {}}
|
||||||
formData={draft}
|
formData={draft}
|
||||||
errors={fieldErrors}
|
errors={fieldErrors}
|
||||||
|
vessels={vessels}
|
||||||
disabled={readOnly}
|
disabled={readOnly}
|
||||||
onChange={(key, value) => {
|
onChange={(key, value) => {
|
||||||
setDraft((prev) => ({
|
setDraft((prev) => ({
|
||||||
@@ -620,17 +710,19 @@ export function LicenseApplicationPage() {
|
|||||||
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
||||||
min={0}
|
min={0}
|
||||||
/>
|
/>
|
||||||
<Button
|
<ModalFooter>
|
||||||
onClick={async () => {
|
<Button
|
||||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
onClick={async () => {
|
||||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||||
setStaffModal(null);
|
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||||
refetch();
|
setStaffModal(null);
|
||||||
}}
|
refetch();
|
||||||
>
|
}}
|
||||||
Add
|
>
|
||||||
</Button>
|
Add
|
||||||
|
</Button>
|
||||||
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Container>
|
</Container>
|
||||||
|
|||||||
@@ -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 { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
|
Alert,
|
||||||
Badge,
|
Badge,
|
||||||
|
Box,
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
Center,
|
|
||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Paper,
|
||||||
|
Progress,
|
||||||
|
Select,
|
||||||
SimpleGrid,
|
SimpleGrid,
|
||||||
|
Skeleton,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
|
TextInput,
|
||||||
|
ThemeIcon,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} 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 { LicenseCatalogue } from '../components/LicenseCatalogue';
|
||||||
|
import { LicenseCard, useRenewLicense } from '../components/LicenseCard';
|
||||||
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
import { useApplicationPayment } from '../../payments/hooks/useApplicationPayment';
|
||||||
import { notifications } from '@mantine/notifications';
|
import { notifications } from '@mantine/notifications';
|
||||||
import {
|
import {
|
||||||
|
APPLICANT_ACTION_STATUSES,
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_PROGRESS,
|
||||||
|
TERMINAL_STATUSES,
|
||||||
|
applicantOrCompanyName,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
localized,
|
localized,
|
||||||
useBypassPaymentMutation,
|
useBypassPaymentMutation,
|
||||||
@@ -27,24 +51,64 @@ import {
|
|||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
useGetPaymentCapabilitiesQuery,
|
useGetPaymentCapabilitiesQuery,
|
||||||
|
type LicenseApplication,
|
||||||
|
type LicenseStatus,
|
||||||
} from '@ema-platform/api';
|
} 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
|
* The applicant's landing page: which licences they can apply for, and the
|
||||||
* state of anything already filed.
|
* state of anything already filed.
|
||||||
*
|
*
|
||||||
* The licence types come from the backend, so a newly configured type appears
|
* Three tabs instead of one long scroll — applications, licences and the
|
||||||
* here without a code change — and each one carries its own document
|
* catalogue each own their own space, so a returning applicant lands on
|
||||||
* requirements into the wizard.
|
* exactly what they came back to check instead of scrolling past it.
|
||||||
*/
|
*/
|
||||||
export function MyApplicationsPage() {
|
export function MyApplicationsPage() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data, isLoading } = useGetMyApplicationsQuery();
|
const { t, i18n } = useTranslation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const { data, isFetching, refetch } = useGetMyApplicationsQuery();
|
||||||
const { pay, isPaying } = useApplicationPayment();
|
const { pay, isPaying } = useApplicationPayment();
|
||||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||||
const { data: licences } = useGetMyLicensesQuery();
|
const { data: licences, isFetching: isFetchingLicences } = useGetMyLicensesQuery();
|
||||||
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
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) {
|
async function handleBypass(applicationId: string) {
|
||||||
try {
|
try {
|
||||||
@@ -53,7 +117,7 @@ export function MyApplicationsPage() {
|
|||||||
color: 'teal',
|
color: 'teal',
|
||||||
title: 'Payment bypassed',
|
title: 'Payment bypassed',
|
||||||
message: result.certificateIssued
|
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()}.`,
|
: `Application is now ${result.status.replace(/_/g, ' ').toLowerCase()}.`,
|
||||||
});
|
});
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -73,15 +137,13 @@ export function MyApplicationsPage() {
|
|||||||
* find it in a separate table.
|
* find it in a separate table.
|
||||||
*/
|
*/
|
||||||
async function openCertificateForApplication(applicationId: string) {
|
async function openCertificateForApplication(applicationId: string) {
|
||||||
const licence = (licences?.items ?? []).find(
|
const licence = (licences?.items ?? []).find((l) => l.applicationId === applicationId);
|
||||||
(l) => l.applicationId === applicationId,
|
|
||||||
);
|
|
||||||
if (!licence) {
|
if (!licence) {
|
||||||
notifications.show({
|
notifications.show({
|
||||||
color: 'yellow',
|
color: 'yellow',
|
||||||
title: 'Certificate not ready',
|
title: 'Certificate not ready',
|
||||||
message:
|
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;
|
return;
|
||||||
}
|
}
|
||||||
@@ -89,6 +151,7 @@ export function MyApplicationsPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function downloadCertificate(licenseId: string) {
|
async function downloadCertificate(licenseId: string) {
|
||||||
|
setIsDownloadingCert(true);
|
||||||
try {
|
try {
|
||||||
const { url } = await getCertificateUrl(licenseId).unwrap();
|
const { url } = await getCertificateUrl(licenseId).unwrap();
|
||||||
window.open(url, '_blank', 'noopener');
|
window.open(url, '_blank', 'noopener');
|
||||||
@@ -98,225 +161,446 @@ export function MyApplicationsPage() {
|
|||||||
title: 'Could not open the certificate',
|
title: 'Could not open the certificate',
|
||||||
message: extractErrorMessage(err),
|
message: extractErrorMessage(err),
|
||||||
});
|
});
|
||||||
|
} finally {
|
||||||
|
setIsDownloadingCert(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading) {
|
const allItems = data?.items ?? [];
|
||||||
return (
|
const licenceItems = licences?.items ?? [];
|
||||||
<Center h={300}>
|
const activeLicences = licenceItems.filter((l) => l.status === 'ACTIVE');
|
||||||
<Loader />
|
const expiringSoon = activeLicences.filter((l) => {
|
||||||
</Center>
|
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 (
|
return (
|
||||||
<Container size="lg" py="md">
|
<Container size="lg" py="lg">
|
||||||
<Title order={3} mb="xs">
|
<Stack gap="lg">
|
||||||
Licence applications
|
<Group justify="space-between" align="flex-end" wrap="wrap">
|
||||||
</Title>
|
<Box>
|
||||||
<Text size="sm" c="dimmed" mb="md">
|
<Title order={2}>{t('applications.title')}</Title>
|
||||||
Your licences and applications, and the catalogue to file a new one.
|
<Text c="dimmed" size="sm" mt={2}>
|
||||||
</Text>
|
{t('applications.subtitle')}
|
||||||
|
|
||||||
{(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.
|
|
||||||
</Text>
|
</Text>
|
||||||
</Stack>
|
</Box>
|
||||||
</Card>
|
<Button leftSection={<IconPlus size={16} />} onClick={() => changeTab('apply')}>
|
||||||
) : (
|
{t('applications.newApplication')}
|
||||||
<Card withBorder padding={0}>
|
</Button>
|
||||||
<Table highlightOnHover>
|
</Group>
|
||||||
<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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Last, for the same reason as on the dashboard: someone opening this
|
<SimpleGrid cols={{ base: 2, md: 4 }} spacing="md">
|
||||||
page came to check on what they already filed, not to browse. */}
|
<StatTile
|
||||||
<Title order={4} mt="xl" mb="sm">
|
label={t('applications.stats.needsYou')}
|
||||||
Apply for a licence
|
value={counts.needsYou}
|
||||||
</Title>
|
icon={IconAlertTriangle}
|
||||||
<LicenseCatalogue />
|
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>
|
</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;
|
export default MyApplicationsPage;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { useState, useMemo, useEffect, useCallback } from 'react';
|
import { useState, useMemo, useEffect, useCallback } from 'react';
|
||||||
import { Stack, Select, Group, Text, Loader, Center, Badge } from '@mantine/core';
|
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 { useGetLocationTypesQuery, useGetLocationsQuery } from '../api/location-api';
|
||||||
import type { Location, LocationType } from '../types/location';
|
import type { Location, LocationType } from '../types/location';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
@@ -9,12 +11,15 @@ interface LocationPickerProps {
|
|||||||
onChange?: (locationId: string | null) => void;
|
onChange?: (locationId: string | null) => void;
|
||||||
onChainChange?: (chain: Location[]) => void;
|
onChainChange?: (chain: Location[]) => void;
|
||||||
required?: boolean;
|
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 { t } = useTranslation();
|
||||||
const { data: typesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
const localized = useLocalized();
|
||||||
const { data: locsRes, isLoading: locsLoading } = useGetLocationsQuery({ take: 10000 });
|
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 locationTypes = typesRes?.items ?? [];
|
||||||
const allLocations = locsRes?.items ?? [];
|
const allLocations = locsRes?.items ?? [];
|
||||||
@@ -71,10 +76,10 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
if (currentLevelChildren.length === 0) return '';
|
if (currentLevelChildren.length === 0) return '';
|
||||||
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
|
const typeIds = [...new Set(currentLevelChildren.map((c) => c.locationTypeId))];
|
||||||
const names = typeIds
|
const names = typeIds
|
||||||
.map((id) => typeMap.get(id)?.names.en)
|
.map((id) => localized(typeMap.get(id)?.names))
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean);
|
||||||
return names.join(' / ');
|
return names.join(' / ');
|
||||||
}, [currentLevelChildren, typeMap]);
|
}, [currentLevelChildren, typeMap, localized]);
|
||||||
|
|
||||||
const depth = selectedChain.length;
|
const depth = selectedChain.length;
|
||||||
|
|
||||||
@@ -113,7 +118,7 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
if (levelIdx === 0) {
|
if (levelIdx === 0) {
|
||||||
const roots = childrenByParentId.get('__root__') ?? [];
|
const roots = childrenByParentId.get('__root__') ?? [];
|
||||||
return roots
|
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));
|
.sort((a, b) => a.label.localeCompare(b.label));
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,7 +126,7 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
if (!parent) return [];
|
if (!parent) return [];
|
||||||
const children = childrenByParentId.get(parent.id) ?? [];
|
const children = childrenByParentId.get(parent.id) ?? [];
|
||||||
return children
|
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));
|
.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 '';
|
if (roots.length === 0) return '';
|
||||||
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
|
const typeIds = [...new Set(roots.map((r) => r.locationTypeId))];
|
||||||
const names = typeIds
|
const names = typeIds
|
||||||
.map((id) => typeMap.get(id)?.names.en)
|
.map((id) => localized(typeMap.get(id)?.names))
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean);
|
||||||
return names.join(' / ');
|
return names.join(' / ');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -141,16 +146,16 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
const children = childrenByParentId.get(parent.id) ?? [];
|
const children = childrenByParentId.get(parent.id) ?? [];
|
||||||
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
|
const typeIds = [...new Set(children.map((c) => c.locationTypeId))];
|
||||||
const names = typeIds
|
const names = typeIds
|
||||||
.map((id) => typeMap.get(id)?.names.en)
|
.map((id) => localized(typeMap.get(id)?.names))
|
||||||
.filter(Boolean) as string[];
|
.filter(Boolean);
|
||||||
return names.join(' / ') || t('location.subLocation');
|
return names.join(' / ');
|
||||||
};
|
};
|
||||||
|
|
||||||
const selectedPath = useMemo(() => {
|
const selectedPath = useMemo(() => {
|
||||||
return selectedChain
|
return selectedChain
|
||||||
.map((loc) => loc.names.en)
|
.map((loc) => localized(loc.names))
|
||||||
.join(' → ');
|
.join(' → ');
|
||||||
}, [selectedChain]);
|
}, [selectedChain, localized]);
|
||||||
|
|
||||||
if (typesLoading || locsLoading) {
|
if (typesLoading || locsLoading) {
|
||||||
return (
|
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__') ?? [];
|
const roots = childrenByParentId.get('__root__') ?? [];
|
||||||
|
|
||||||
if (roots.length === 0) {
|
if (roots.length === 0) {
|
||||||
@@ -170,9 +187,13 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const totalRenderedLevels = Math.max(
|
// Only offer a further level when its location type resolves to a known
|
||||||
1,
|
// name — an unnamed/unmapped type (e.g. a stray Kebele row) would otherwise
|
||||||
selectedChain.length + (currentLevelChildren.length > 0 ? 1 : 0),
|
// 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);
|
const levels = Array.from({ length: totalRenderedLevels }, (_, i) => i);
|
||||||
@@ -217,8 +238,8 @@ export function LocationPicker({ value, onChange, onChainChange, required }: Loc
|
|||||||
color="blue"
|
color="blue"
|
||||||
style={{ textTransform: 'none' }}
|
style={{ textTransform: 'none' }}
|
||||||
>
|
>
|
||||||
{typeInfo ? `${typeInfo.names.en}: ` : ''}
|
{typeInfo ? `${localized(typeInfo.names)}: ` : ''}
|
||||||
{loc.names.en}
|
{localized(loc.names)}
|
||||||
</Badge>
|
</Badge>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { useState } from 'react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
Badge,
|
Badge,
|
||||||
@@ -7,16 +8,27 @@ import {
|
|||||||
Container,
|
Container,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
|
SegmentedControl,
|
||||||
Stack,
|
Stack,
|
||||||
Text,
|
Text,
|
||||||
Title,
|
Title,
|
||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
import { IconBellOff, IconCheck } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
localized,
|
useLocalized,
|
||||||
useGetNotificationsQuery,
|
useGetNotificationsQuery,
|
||||||
|
useGetUnseenNotificationsQuery,
|
||||||
useMarkNotificationReadMutation,
|
useMarkNotificationReadMutation,
|
||||||
} from '@ema-platform/api';
|
} 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.
|
* The applicant's notification inbox.
|
||||||
@@ -26,19 +38,17 @@ import {
|
|||||||
*/
|
*/
|
||||||
export function NotificationsPage() {
|
export function NotificationsPage() {
|
||||||
const navigate = useNavigate();
|
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();
|
const [markRead] = useMarkNotificationReadMutation();
|
||||||
|
|
||||||
if (isLoading) {
|
const { data, isLoading } = tab === 'unseen' ? unseen : all;
|
||||||
return (
|
const items =
|
||||||
<Center h={300}>
|
tab === 'seen' ? (data?.items ?? []).filter((n) => n.isSeen) : (data?.items ?? []);
|
||||||
<Loader />
|
const unread = all.data?.items.filter((n) => !n.isSeen).length ?? 0;
|
||||||
</Center>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const items = data?.items ?? [];
|
|
||||||
const unread = items.filter((n) => !n.isSeen).length;
|
|
||||||
|
|
||||||
async function open(id: string, seen: boolean, link?: string) {
|
async function open(id: string, seen: boolean, link?: string) {
|
||||||
if (!seen) await markRead(id);
|
if (!seen) await markRead(id);
|
||||||
@@ -52,11 +62,27 @@ export function NotificationsPage() {
|
|||||||
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
|
{unread > 0 ? `${unread} unread` : 'You are all caught up'}
|
||||||
</Text>
|
</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">
|
<Card withBorder padding="xl">
|
||||||
<Stack align="center" gap="xs">
|
<Stack align="center" gap="xs">
|
||||||
<IconBellOff size={32} stroke={1.4} color="var(--mantine-color-gray-5)" />
|
<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">
|
<Text size="sm" c="dimmed">
|
||||||
You will be notified as your applications progress.
|
You will be notified as your applications progress.
|
||||||
</Text>
|
</Text>
|
||||||
@@ -94,7 +120,7 @@ export function NotificationsPage() {
|
|||||||
{localized(n.content)}
|
{localized(n.content)}
|
||||||
</Text>
|
</Text>
|
||||||
<Text size="xs" c="dimmed" mt={4}>
|
<Text size="xs" c="dimmed" mt={4}>
|
||||||
{new Date(n.createdAt).toLocaleString()}
|
{showDate(n.createdAt)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
{!n.isSeen && (
|
{!n.isSeen && (
|
||||||
|
|||||||
@@ -49,8 +49,11 @@ const ALWAYS_ALLOWED = [
|
|||||||
const MODE_FREE_TYPE_KEYS = [
|
const MODE_FREE_TYPE_KEYS = [
|
||||||
'SEAFARER_REGISTRATION',
|
'SEAFARER_REGISTRATION',
|
||||||
'VESSEL_REGISTRATION',
|
'VESSEL_REGISTRATION',
|
||||||
|
'VESSEL_OWNERSHIP_TRANSFER',
|
||||||
'CERTIFICATE_OF_COMPETENCY',
|
'CERTIFICATE_OF_COMPETENCY',
|
||||||
'CERTIFICATE_OF_PROFICIENCY',
|
'CERTIFICATE_OF_PROFICIENCY',
|
||||||
|
'ENDORSEMENT_COC',
|
||||||
|
'ENDORSEMENT_GOC',
|
||||||
'PRE_WAIVER',
|
'PRE_WAIVER',
|
||||||
'POST_WAIVER',
|
'POST_WAIVER',
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -12,11 +12,13 @@ import {
|
|||||||
} from '@mantine/core';
|
} from '@mantine/core';
|
||||||
import { IconCircleCheck } from '@tabler/icons-react';
|
import { IconCircleCheck } from '@tabler/icons-react';
|
||||||
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
import { useGetApplicationPaymentQuery } from '@ema-platform/api';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
/** Confirmation that the licence fee has been received. */
|
/** Confirmation that the licence fee has been received. */
|
||||||
export function PaymentSuccessPage() {
|
export function PaymentSuccessPage() {
|
||||||
const [params] = useSearchParams();
|
const [params] = useSearchParams();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
const applicationId = params.get('applicationId') ?? '';
|
const applicationId = params.get('applicationId') ?? '';
|
||||||
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
const { data } = useGetApplicationPaymentQuery(applicationId, {
|
||||||
skip: !applicationId,
|
skip: !applicationId,
|
||||||
@@ -58,7 +60,7 @@ export function PaymentSuccessPage() {
|
|||||||
{data.paidAt && (
|
{data.paidAt && (
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="sm" c="dimmed">Paid</Text>
|
<Text size="sm" c="dimmed">Paid</Text>
|
||||||
<Text size="sm">{new Date(data.paidAt).toLocaleString()}</Text>
|
<Text size="sm">{showDate(data.paidAt)}</Text>
|
||||||
</Group>
|
</Group>
|
||||||
)}
|
)}
|
||||||
</Stack>
|
</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 { Select, SimpleGrid, Text, TextInput } from '@mantine/core';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { ethiopianPhone, optionalEthiopianPhone, CountrySelect } from '@ema-platform/ui';
|
||||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||||
import { useGetLocationTypesQuery } from '../../location/api/location-api';
|
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({
|
export const addressSchema = z.object({
|
||||||
idType: z.string().min(1, 'Select ID type'),
|
idType: z.string().min(1, 'Select ID type'),
|
||||||
idNumber: z.string().min(1, 'Enter ID number'),
|
idNumber: z.string().trim().min(1, 'Enter ID number'),
|
||||||
nationality: z.string().min(1, 'Enter nationality'),
|
// Alpha-2 country code from CountrySelect; converted to a full name at submit.
|
||||||
primaryPhoneNumber: z.string().min(1, 'Enter primary phone number'),
|
nationality: z.string().min(1, 'Select nationality'),
|
||||||
secondaryPhoneNumber: z.string().optional(),
|
primaryPhoneNumber: ethiopianPhone,
|
||||||
email: z.string().email('Invalid email').optional().or(z.literal('')),
|
secondaryPhoneNumber: optionalEthiopianPhone,
|
||||||
|
email: z.string().trim().email('Invalid email').optional().or(z.literal('')),
|
||||||
regionId: z.string().optional(),
|
regionId: z.string().optional(),
|
||||||
cityId: z.string().optional(),
|
cityId: z.string().optional(),
|
||||||
subcityId: z.string().optional(),
|
subCityId: z.string().optional(),
|
||||||
woredaId: z.string().optional(),
|
woredaId: z.string().optional(),
|
||||||
kebeleId: z.string().optional(),
|
kebeleId: z.string().optional(),
|
||||||
streetAddress: z.string().optional(),
|
streetAddress: z.string().trim().optional(),
|
||||||
postalAddress: z.string().optional(),
|
postalAddress: z.string().trim().optional(),
|
||||||
// Emergency contact is collected but never required — leaving it blank must
|
// Emergency contact is collected but never required — leaving it blank must
|
||||||
// not stop an applicant moving on.
|
// not stop an applicant moving on.
|
||||||
emergencyContactName: z.string().optional(),
|
emergencyContactName: z.string().trim().optional(),
|
||||||
emergencyContactPhone: z.string().optional(),
|
emergencyContactPhone: optionalEthiopianPhone,
|
||||||
emergencyContactRelation: z.string().optional(),
|
emergencyContactRelation: z.string().trim().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type AddressValues = z.infer<typeof addressSchema>;
|
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',
|
* Location types are data-driven rows (no fixed depth), so the chain is
|
||||||
2: 'subcityId',
|
* mapped to a field by type *code* rather than by numeric level — this
|
||||||
3: 'woredaId',
|
* resolves correctly whether or not a COUNTRY type sits above REGION.
|
||||||
4: 'kebeleId',
|
*/
|
||||||
};
|
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 {
|
interface AddressFormContentProps {
|
||||||
register: UseFormRegister<AddressValues>;
|
register: UseFormRegister<AddressValues>;
|
||||||
@@ -54,35 +80,33 @@ export function AddressFormContent({
|
|||||||
trigger,
|
trigger,
|
||||||
}: AddressFormContentProps) {
|
}: AddressFormContentProps) {
|
||||||
const { data: typesRes } = useGetLocationTypesQuery();
|
const { data: typesRes } = useGetLocationTypesQuery();
|
||||||
const locationTypes = typesRes?.items ?? [];
|
const locationTypes = typesRes?.items;
|
||||||
|
|
||||||
const typeLevelMap = useMemo(() => {
|
const typeFieldMap = useMemo(() => {
|
||||||
const map = new Map<string, number>();
|
const map = new Map<string, Array<keyof AddressValues>>();
|
||||||
locationTypes.forEach((lt) => map.set(lt.id, lt.level));
|
locationTypes?.forEach((lt) => {
|
||||||
|
const fields = fieldsForLocationType(lt);
|
||||||
|
if (fields.length) map.set(lt.id, fields);
|
||||||
|
});
|
||||||
return map;
|
return map;
|
||||||
}, [locationTypes]);
|
}, [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(
|
const handleChainChange = useCallback(
|
||||||
(chain: Location[]) => {
|
(chain: Location[]) => {
|
||||||
if (chain.length > 0 && !typeLevelMap.has(chain[0].locationTypeId)) {
|
setValue('regionId', '');
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setValue('cityId', '');
|
setValue('cityId', '');
|
||||||
setValue('subcityId', '');
|
setValue('subCityId', '');
|
||||||
setValue('woredaId', '');
|
setValue('woredaId', '');
|
||||||
setValue('kebeleId', '');
|
|
||||||
|
|
||||||
chain.forEach((loc) => {
|
chain.forEach((loc) => {
|
||||||
const level = typeLevelMap.get(loc.locationTypeId);
|
typeFieldMap.get(loc.locationTypeId)?.forEach((field) => {
|
||||||
if (level && LEVEL_TO_FIELD[level]) {
|
setValue(field, loc.id);
|
||||||
setValue(LEVEL_TO_FIELD[level], loc.id);
|
});
|
||||||
}
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
[setValue, typeLevelMap],
|
[setValue, typeFieldMap],
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -92,7 +116,7 @@ export function AddressFormContent({
|
|||||||
label="ID Type"
|
label="ID Type"
|
||||||
placeholder="Select"
|
placeholder="Select"
|
||||||
required
|
required
|
||||||
data={[...ID_TYPES]}
|
data={ID_TYPES}
|
||||||
error={errors.idType?.message}
|
error={errors.idType?.message}
|
||||||
value={watch('idType')}
|
value={watch('idType')}
|
||||||
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
onChange={(val) => setValue('idType', val || '', { shouldValidate: true })}
|
||||||
@@ -106,17 +130,19 @@ export function AddressFormContent({
|
|||||||
{...register('idNumber')}
|
{...register('idNumber')}
|
||||||
error={errors.idNumber?.message}
|
error={errors.idNumber?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<CountrySelect
|
||||||
label="Nationality"
|
label="Nationality"
|
||||||
placeholder="e.g. Ethiopian"
|
demonym
|
||||||
required
|
required
|
||||||
{...register('nationality')}
|
value={watch('nationality') || null}
|
||||||
|
onChange={(val) => setValue('nationality', val || '', { shouldValidate: true })}
|
||||||
error={errors.nationality?.message}
|
error={errors.nationality?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Primary Phone"
|
label="Primary Phone"
|
||||||
placeholder="+251 9XX XXX XXX"
|
description="From your account, edit it in the Personal tab"
|
||||||
required
|
required
|
||||||
|
readOnly
|
||||||
{...register('primaryPhoneNumber')}
|
{...register('primaryPhoneNumber')}
|
||||||
error={errors.primaryPhoneNumber?.message}
|
error={errors.primaryPhoneNumber?.message}
|
||||||
/>
|
/>
|
||||||
@@ -129,7 +155,8 @@ export function AddressFormContent({
|
|||||||
<TextInput
|
<TextInput
|
||||||
label="Email"
|
label="Email"
|
||||||
type="email"
|
type="email"
|
||||||
placeholder="email@example.com"
|
description="From your account, edit it in the Personal tab"
|
||||||
|
readOnly
|
||||||
{...register('email')}
|
{...register('email')}
|
||||||
error={errors.email?.message}
|
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">
|
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
||||||
Address
|
Address
|
||||||
</Text>
|
</Text>
|
||||||
<LocationPicker
|
{/* City / Sub-city / Woreda only — no Kebele level, kebeleId mirrors woredaId. */}
|
||||||
value={leafId}
|
<LocationPicker value={leafId} onChainChange={handleChainChange} maxDepth={3} />
|
||||||
onChainChange={handleChainChange}
|
|
||||||
/>
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
<SimpleGrid cols={{ base: 1, sm: 2, lg: 3 }} spacing="md" mt="md">
|
||||||
<TextInput
|
<TextInput
|
||||||
label="Street Address"
|
label="Street Address"
|
||||||
@@ -149,6 +174,12 @@ export function AddressFormContent({
|
|||||||
{...register('streetAddress')}
|
{...register('streetAddress')}
|
||||||
error={errors.streetAddress?.message}
|
error={errors.streetAddress?.message}
|
||||||
/>
|
/>
|
||||||
|
<TextInput
|
||||||
|
label="Postal Address"
|
||||||
|
placeholder="P.O. Box"
|
||||||
|
{...register('postalAddress')}
|
||||||
|
error={errors.postalAddress?.message}
|
||||||
|
/>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6" mt="lg" mb="sm">
|
<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 { IconAlertTriangle, IconBuildingWarehouse } from '@tabler/icons-react';
|
||||||
import {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
localized,
|
useLocalized,
|
||||||
useGetLicenseTypesQuery,
|
useGetLicenseTypesQuery,
|
||||||
useGetMyOperatorTypesQuery,
|
useGetMyOperatorTypesQuery,
|
||||||
useUpdateMyOperatorTypesMutation,
|
useUpdateMyOperatorTypesMutation,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The applicant's modes of operation — what they do, and therefore which
|
* 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: catalogue, isLoading: loadingTypes } = useGetLicenseTypesQuery();
|
||||||
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
const { data: mine, isLoading: loadingMine } = useGetMyOperatorTypesQuery();
|
||||||
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
const [save, { isLoading: saving }] = useUpdateMyOperatorTypesMutation();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
const declaredIds = useMemo(
|
const declaredIds = useMemo(
|
||||||
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
|
() => (mine?.items ?? []).map((o) => o.licenseTypeId),
|
||||||
@@ -62,6 +64,7 @@ export function OperationsFormContent({
|
|||||||
[catalogue],
|
[catalogue],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
const removed = declaredIds.filter((id) => !selected.includes(id));
|
const removed = declaredIds.filter((id) => !selected.includes(id));
|
||||||
const dirty =
|
const dirty =
|
||||||
removed.length > 0 || selected.some((id) => !declaredIds.includes(id));
|
removed.length > 0 || selected.some((id) => !declaredIds.includes(id));
|
||||||
@@ -151,13 +154,7 @@ export function OperationsFormContent({
|
|||||||
|
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{lastChanged
|
{lastChanged ? `Last changed ${showDate(lastChanged)}` : 'Not set yet'}
|
||||||
? `Last changed ${new Date(lastChanged).toLocaleDateString('en-GB', {
|
|
||||||
day: 'numeric',
|
|
||||||
month: 'short',
|
|
||||||
year: 'numeric',
|
|
||||||
})}`
|
|
||||||
: 'Not set yet'}
|
|
||||||
</Text>
|
</Text>
|
||||||
<Group gap="sm">
|
<Group gap="sm">
|
||||||
{dirty && (
|
{dirty && (
|
||||||
@@ -204,7 +201,7 @@ export function OperationsFormContent({
|
|||||||
Applications already filed carry on as they are, and licences
|
Applications already filed carry on as they are, and licences
|
||||||
already issued to you stay valid and can still be renewed.
|
already issued to you stay valid and can still be renewed.
|
||||||
</Text>
|
</Text>
|
||||||
<Group justify="flex-end" gap="sm">
|
<ModalFooter gap="sm">
|
||||||
<Button
|
<Button
|
||||||
variant="default"
|
variant="default"
|
||||||
onClick={() => setConfirmingRemoval(false)}
|
onClick={() => setConfirmingRemoval(false)}
|
||||||
@@ -214,7 +211,7 @@ export function OperationsFormContent({
|
|||||||
<Button color="orange" loading={saving} onClick={persist}>
|
<Button color="orange" loading={saving} onClick={persist}>
|
||||||
Remove and save
|
Remove and save
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</ModalFooter>
|
||||||
</Stack>
|
</Stack>
|
||||||
</Modal>
|
</Modal>
|
||||||
</Stack>
|
</Stack>
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
import { Loader, Select, SimpleGrid, TextInput } from '@mantine/core';
|
||||||
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
import type { FieldErrors, UseFormRegister, UseFormSetValue, UseFormWatch, UseFormTrigger } from 'react-hook-form';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { AmharicDatePicker } from '@ema-platform/ui';
|
||||||
|
|
||||||
export const profileSchema = z.object({
|
export const profileSchema = z.object({
|
||||||
professionId: z.string().min(1, 'Select your profession'),
|
professionId: z.string().min(1, 'Select your profession'),
|
||||||
@@ -86,11 +87,13 @@ export function ProfileFormContent({
|
|||||||
onBlur={() => trigger('gender')}
|
onBlur={() => trigger('gender')}
|
||||||
name="gender"
|
name="gender"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
label="Date of Birth"
|
label="Date of Birth"
|
||||||
type="date"
|
|
||||||
required
|
required
|
||||||
{...register('dob')}
|
value={watch('dob')}
|
||||||
|
onChange={(val) => setValue('dob', val, { shouldValidate: true })}
|
||||||
|
onBlur={() => trigger('dob')}
|
||||||
|
name="dob"
|
||||||
error={errors.dob?.message}
|
error={errors.dob?.message}
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<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;
|
display: inline-flex;
|
||||||
gap: 6px;
|
gap: 6px;
|
||||||
padding: 5px;
|
padding: 5px;
|
||||||
background: var(--mantine-color-gray-1);
|
background: var(--mantine-color-gray-light);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
border: none;
|
border: none;
|
||||||
flex-wrap: wrap;
|
flex-wrap: wrap;
|
||||||
@@ -14,7 +14,7 @@
|
|||||||
border-radius: 10px;
|
border-radius: 10px;
|
||||||
padding: 9px 18px;
|
padding: 9px 18px;
|
||||||
font-weight: 500;
|
font-weight: 500;
|
||||||
color: var(--mantine-color-gray-7);
|
color: var(--mantine-color-dimmed);
|
||||||
background: transparent;
|
background: transparent;
|
||||||
transition:
|
transition:
|
||||||
background-color 120ms ease,
|
background-color 120ms ease,
|
||||||
@@ -24,7 +24,7 @@
|
|||||||
|
|
||||||
.tab:hover {
|
.tab:hover {
|
||||||
background: transparent;
|
background: transparent;
|
||||||
color: var(--mantine-color-gray-9);
|
color: var(--mantine-color-text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.tab[data-active],
|
.tab[data-active],
|
||||||
@@ -37,7 +37,7 @@
|
|||||||
|
|
||||||
/* Selectable option card (language + appearance). */
|
/* Selectable option card (language + appearance). */
|
||||||
.choice {
|
.choice {
|
||||||
border: 1px solid var(--mantine-color-gray-3);
|
border: 1px solid var(--mantine-color-default-border);
|
||||||
border-radius: var(--mantine-radius-md);
|
border-radius: var(--mantine-radius-md);
|
||||||
background: var(--mantine-color-body);
|
background: var(--mantine-color-body);
|
||||||
transition:
|
transition:
|
||||||
@@ -46,11 +46,11 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
.choice:hover {
|
.choice:hover {
|
||||||
border-color: var(--mantine-color-gray-4);
|
border-color: var(--mantine-color-gray-5);
|
||||||
}
|
}
|
||||||
|
|
||||||
.choiceActive,
|
.choiceActive,
|
||||||
.choiceActive:hover {
|
.choiceActive:hover {
|
||||||
border-color: var(--mantine-color-emaPrimary-6);
|
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 { zodResolver } from '@hookform/resolvers/zod';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { notify, PageHeader } from '@ema-platform/ui';
|
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, getCountryCode } from '@ema-platform/ui';
|
||||||
import { useApiMutation } from '@ema-platform/api';
|
import { useApiMutation, useLocalized } from '@ema-platform/api';
|
||||||
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
import { setUser, useCurrentProfile } from '@ema-platform/auth';
|
||||||
import type { CurrentProfile } from '@ema-platform/auth';
|
|
||||||
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
import { SUPPORTED_LANGUAGES, type AppLanguage } from '../../../i18n/config';
|
||||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||||
import type { AuthUser } from '@ema-platform/auth';
|
import type { AuthUser } from '@ema-platform/auth';
|
||||||
@@ -64,6 +63,8 @@ import {
|
|||||||
addressSchema,
|
addressSchema,
|
||||||
type AddressValues,
|
type AddressValues,
|
||||||
} from '../components/AddressFormContent';
|
} from '../components/AddressFormContent';
|
||||||
|
import { useSaveMyAddressMutation } from '../api/address-api';
|
||||||
|
import { toAddressPayload } from '../types/address';
|
||||||
import { OperationsFormContent } from '../components/OperationsFormContent';
|
import { OperationsFormContent } from '../components/OperationsFormContent';
|
||||||
import classes from './ProfilePage.module.css';
|
import classes from './ProfilePage.module.css';
|
||||||
|
|
||||||
@@ -85,6 +86,19 @@ function getInitials(name: string, fallback: string) {
|
|||||||
return letters.toUpperCase();
|
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) {
|
function passwordScore(pw: string) {
|
||||||
if (!pw) return 0;
|
if (!pw) return 0;
|
||||||
let score = 0;
|
let score = 0;
|
||||||
@@ -101,22 +115,22 @@ export function ProfilePage() {
|
|||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
const storedProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||||
|
const { handleError } = useErrorHandler();
|
||||||
|
|
||||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||||
const [meTrigger] = useApiMutation<AuthUser>();
|
|
||||||
const [passwordTrigger] = useApiMutation<unknown>();
|
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 [isSavingProfile, setIsSavingProfile] = useState(false);
|
||||||
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
const [isSavingPassword, setIsSavingPassword] = useState(false);
|
||||||
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
const [isSavingMaritime, setIsSavingMaritime] = useState(false);
|
||||||
const [isSavingAddress, setIsSavingAddress] = useState(false);
|
|
||||||
|
|
||||||
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
const [twoStepEnabled, setTwoStepEnabled] = useState(false);
|
||||||
const [emailNotifications, setEmailNotifications] = useState(true);
|
const [emailNotifications, setEmailNotifications] = useState(true);
|
||||||
|
|
||||||
// ---- Profession list (for Profile tab) ----
|
// ---- 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 [professionsLoading, setProfessionsLoading] = useState(true);
|
||||||
const professionsFetched = useRef(false);
|
const professionsFetched = useRef(false);
|
||||||
|
|
||||||
@@ -131,34 +145,28 @@ export function ProfilePage() {
|
|||||||
}, [fetchProfessions]);
|
}, [fetchProfessions]);
|
||||||
|
|
||||||
const professionOptions = useMemo(
|
const professionOptions = useMemo(
|
||||||
() => professions.map((p) => ({ value: p.id, label: p.name.en })),
|
() => professions.map((p) => ({ value: p.id, label: localized(p.name) })),
|
||||||
[professions],
|
[professions, localized],
|
||||||
);
|
);
|
||||||
|
|
||||||
const professionNameMap = useMemo(() => {
|
|
||||||
const map: Record<string, string> = {};
|
|
||||||
professions.forEach((p) => { map[p.id] = p.name.en; });
|
|
||||||
return map;
|
|
||||||
}, [professions]);
|
|
||||||
|
|
||||||
// ---- Profile data ----
|
// ---- Profile data ----
|
||||||
// Resolved through `useCurrentProfile`, which provisions a profile if the
|
// 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
|
// 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
|
// the deleted setup wizard ever wrote, so it rendered an empty form forever
|
||||||
// for anyone who signed up after the wizard was removed.
|
// for anyone who signed up after the wizard was removed.
|
||||||
const {
|
const {
|
||||||
|
profileId,
|
||||||
profile: resolvedProfile,
|
profile: resolvedProfile,
|
||||||
isLoading: profileResolving,
|
isLoading: profileResolving,
|
||||||
completeness,
|
completeness,
|
||||||
missing,
|
missing,
|
||||||
|
refetch: refetchProfile,
|
||||||
} = useCurrentProfile();
|
} = useCurrentProfile();
|
||||||
const [updateProfile] = useApiMutation<unknown>();
|
const [updateProfile] = useApiMutation<unknown>();
|
||||||
const [updateAddress] = useApiMutation<unknown>();
|
const [saveMyAddress, { isLoading: isSavingAddress }] = useSaveMyAddressMutation();
|
||||||
|
|
||||||
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
const [loadedProfile, setLoadedProfile] = useState<ProfileValues | null>(null);
|
||||||
const [loadedAddress, setLoadedAddress] = useState<AddressValues | 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);
|
const [dataLoading, setDataLoading] = useState(true);
|
||||||
|
|
||||||
// Deep links. `useCurrentProfile` reports gaps by section, and the nudge and
|
// 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.
|
// already holds so the form does not flash empty on a refetch.
|
||||||
const currentProfile = resolvedProfile ?? storedProfile;
|
const currentProfile = resolvedProfile ?? storedProfile;
|
||||||
if (currentProfile) {
|
if (currentProfile) {
|
||||||
setProfileId(currentProfile.id);
|
const accountName = user?.name?.en ? splitProfileName(user.name.en) : null;
|
||||||
setLoadedProfile({
|
setLoadedProfile({
|
||||||
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
professionId: currentProfile.professionId || currentProfile.profession?.id || '',
|
||||||
firstName: currentProfile.firstName || '',
|
firstName: accountName?.firstName || currentProfile.firstName || '',
|
||||||
middleName: currentProfile.middleName || '',
|
middleName: accountName?.middleName || currentProfile.middleName || '',
|
||||||
lastName: currentProfile.lastName || '',
|
lastName: accountName?.lastName || currentProfile.lastName || '',
|
||||||
gender: currentProfile.gender || '',
|
gender: currentProfile.gender || '',
|
||||||
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
dob: currentProfile.dob ? currentProfile.dob.split('T')[0] : '',
|
||||||
pob: currentProfile.pob || '',
|
pob: currentProfile.pob || '',
|
||||||
maritalStatus: currentProfile.maritalStatus || '',
|
maritalStatus: currentProfile.maritalStatus || '',
|
||||||
});
|
});
|
||||||
|
|
||||||
if (currentProfile.address) {
|
// Primary phone and email are the account's contact details (same
|
||||||
setAddressId(currentProfile.address.id);
|
// source as the Personal tab), not the address record — always
|
||||||
setLoadedAddress({
|
// populated even before an address exists, and locked in the form.
|
||||||
idType: currentProfile.address.idType || '',
|
setLoadedAddress({
|
||||||
idNumber: currentProfile.address.idNumber || '',
|
idType: currentProfile.address?.idType || '',
|
||||||
nationality: currentProfile.address.nationality || '',
|
idNumber: currentProfile.address?.idNumber || '',
|
||||||
primaryPhoneNumber: currentProfile.address.primaryPhoneNumber || '',
|
// Stored as a country name; the select works in alpha-2 codes.
|
||||||
secondaryPhoneNumber: currentProfile.address.secondaryPhoneNumber || '',
|
nationality: getCountryCode(currentProfile.address?.nationality) || '',
|
||||||
email: currentProfile.address.email || '',
|
primaryPhoneNumber: user?.phoneNumber || '',
|
||||||
regionId: currentProfile.address.regionId || '',
|
secondaryPhoneNumber: currentProfile.address?.secondaryPhoneNumber || '',
|
||||||
cityId: currentProfile.address.cityId || '',
|
email: user?.email || '',
|
||||||
subcityId: currentProfile.address.subCityId || '',
|
regionId: currentProfile.address?.regionId || '',
|
||||||
woredaId: currentProfile.address.woredaId || '',
|
cityId: currentProfile.address?.cityId || currentProfile.address?.regionId || '',
|
||||||
kebeleId: currentProfile.address.kebeleId || '',
|
subCityId: currentProfile.address?.subCityId || '',
|
||||||
streetAddress: currentProfile.address.streetAddress || '',
|
woredaId: currentProfile.address?.woredaId || '',
|
||||||
postalAddress: currentProfile.address.postalAddress || '',
|
streetAddress: currentProfile.address?.streetAddress || '',
|
||||||
emergencyContactName: currentProfile.address.emergencyContactName || '',
|
postalAddress: currentProfile.address?.postalAddress || '',
|
||||||
emergencyContactPhone: currentProfile.address.emergencyContactPhone || '',
|
emergencyContactName: currentProfile.address?.emergencyContactName || '',
|
||||||
// Previously read `emergencycontactRelation` (lower-case c), so the
|
emergencyContactPhone: currentProfile.address?.emergencyContactPhone || '',
|
||||||
// saved relationship never appeared when reopening the profile.
|
// Previously read `emergencycontactRelation` (lower-case c), so the
|
||||||
emergencyContactRelation:
|
// saved relationship never appeared when reopening the profile.
|
||||||
currentProfile.address.emergencyContactRelation || '',
|
emergencyContactRelation:
|
||||||
});
|
currentProfile.address?.emergencyContactRelation || '',
|
||||||
}
|
});
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
} else if (!profileResolving) {
|
} else if (!profileResolving) {
|
||||||
// Resolver finished and there is still nothing — render the empty form
|
// Resolver finished and there is still nothing — render the empty form
|
||||||
// rather than an indefinite spinner.
|
// rather than an indefinite spinner.
|
||||||
setDataLoading(false);
|
setDataLoading(false);
|
||||||
}
|
}
|
||||||
}, [resolvedProfile, storedProfile, profileResolving]);
|
}, [resolvedProfile, storedProfile, profileResolving, user]);
|
||||||
|
|
||||||
// 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
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// ---- Personal form (auth user data) ----
|
// ---- Personal form (auth user data) ----
|
||||||
const personalSchema = z.object({
|
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') }),
|
nameAm: z.string().min(1, { message: t('profile.validation.nameRequired') }),
|
||||||
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
username: z.string().min(1, { message: t('profile.validation.usernameRequired') }),
|
||||||
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
email: z.string().email({ message: t('profile.validation.emailInvalid') }),
|
||||||
@@ -269,25 +267,66 @@ export function ProfilePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSavePersonal = async (values: PersonalValues) => {
|
const onSavePersonal = async (values: PersonalValues) => {
|
||||||
|
if (!user) return;
|
||||||
|
|
||||||
setIsSavingProfile(true);
|
setIsSavingProfile(true);
|
||||||
try {
|
try {
|
||||||
await updateTrigger({
|
const profileName = splitProfileName(values.nameEn);
|
||||||
url: '/auth/update-profile',
|
const saves: Promise<unknown>[] = [
|
||||||
method: 'PATCH',
|
updateTrigger({
|
||||||
body: {
|
url: '/auth/update-profile',
|
||||||
email: values.email,
|
method: 'PATCH',
|
||||||
username: values.username,
|
body: {
|
||||||
phoneNumber: values.phoneNumber,
|
email: values.email,
|
||||||
name: { am: values.nameAm, en: values.nameEn },
|
username: values.username,
|
||||||
},
|
phoneNumber: values.phoneNumber,
|
||||||
}).unwrap();
|
name: { am: values.nameAm, en: values.nameEn },
|
||||||
|
},
|
||||||
|
}).unwrap(),
|
||||||
|
];
|
||||||
|
|
||||||
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
|
// The Profile tab stores names separately as first/middle/last.
|
||||||
dispatch(setUser(me));
|
// 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'));
|
notify.success(t('profile.profileUpdated'));
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('profile.updateFailed'));
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingProfile(false);
|
setIsSavingProfile(false);
|
||||||
}
|
}
|
||||||
@@ -308,6 +347,13 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
const onSaveProfile = async (values: ProfileValues) => {
|
const onSaveProfile = async (values: ProfileValues) => {
|
||||||
if (!profileId) return;
|
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);
|
setIsSavingMaritime(true);
|
||||||
try {
|
try {
|
||||||
await updateProfile({
|
await updateProfile({
|
||||||
@@ -315,10 +361,15 @@ export function ProfilePage() {
|
|||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
body: values,
|
body: values,
|
||||||
}).unwrap();
|
}).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');
|
notify.success('Profile updated');
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error('Failed to update profile');
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingMaritime(false);
|
setIsSavingMaritime(false);
|
||||||
}
|
}
|
||||||
@@ -338,23 +389,15 @@ export function ProfilePage() {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const onSaveAddress = async (values: AddressValues) => {
|
const onSaveAddress = async (values: AddressValues) => {
|
||||||
if (!addressId) return;
|
if (!profileId) return;
|
||||||
setIsSavingAddress(true);
|
|
||||||
try {
|
try {
|
||||||
await updateAddress({
|
await saveMyAddress({
|
||||||
url: `/addresss/${addressId}`,
|
profileId,
|
||||||
method: 'PUT',
|
body: toAddressPayload(values),
|
||||||
body: {
|
|
||||||
...values,
|
|
||||||
postalAddess: values.postalAddress,
|
|
||||||
},
|
|
||||||
}).unwrap();
|
}).unwrap();
|
||||||
|
notify.success('Address saved');
|
||||||
notify.success('Address updated');
|
} catch (e) {
|
||||||
} catch {
|
handleError(e);
|
||||||
notify.error('Failed to update address');
|
|
||||||
} finally {
|
|
||||||
setIsSavingAddress(false);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -362,8 +405,8 @@ export function ProfilePage() {
|
|||||||
const passwordSchema = z
|
const passwordSchema = z
|
||||||
.object({
|
.object({
|
||||||
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
oldPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
newPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
newPassword: strongPasswordSchema(8),
|
||||||
confirmPassword: z.string().min(8, { message: t('profile.validation.passwordMin') }),
|
confirmPassword: z.string().min(1, { message: t('profile.validation.passwordMin') }),
|
||||||
})
|
})
|
||||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||||
message: t('profile.validation.passwordMismatch'),
|
message: t('profile.validation.passwordMismatch'),
|
||||||
@@ -397,8 +440,8 @@ export function ProfilePage() {
|
|||||||
|
|
||||||
notify.success(t('profile.passwordChanged'));
|
notify.success(t('profile.passwordChanged'));
|
||||||
resetPassword();
|
resetPassword();
|
||||||
} catch {
|
} catch (e) {
|
||||||
notify.error(t('profile.passwordFailed'));
|
handleError(e);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSavingPassword(false);
|
setIsSavingPassword(false);
|
||||||
}
|
}
|
||||||
@@ -674,10 +717,6 @@ export function ProfilePage() {
|
|||||||
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
<Paper p="xl" shadow="sm" radius="lg" withBorder>
|
||||||
{dataLoading ? (
|
{dataLoading ? (
|
||||||
<Center py="xl"><Loader /></Center>
|
<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)}>
|
<form onSubmit={handleAddressSubmit(onSaveAddress)}>
|
||||||
<Stack gap="xl">
|
<Stack gap="xl">
|
||||||
@@ -736,12 +775,15 @@ export function ProfilePage() {
|
|||||||
{...registerPassword('oldPassword')}
|
{...registerPassword('oldPassword')}
|
||||||
/>
|
/>
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<PasswordInput
|
<div>
|
||||||
label={t('profile.fields.newPassword')}
|
<PasswordInput
|
||||||
leftSection={<IconLock size={18} />}
|
label={t('profile.fields.newPassword')}
|
||||||
error={passwordErrors.newPassword?.message}
|
leftSection={<IconLock size={18} />}
|
||||||
{...registerPassword('newPassword')}
|
error={passwordErrors.newPassword?.message}
|
||||||
/>
|
{...registerPassword('newPassword')}
|
||||||
|
/>
|
||||||
|
<PasswordRequirements password={watchPassword('newPassword')} minLength={8} />
|
||||||
|
</div>
|
||||||
<PasswordInput
|
<PasswordInput
|
||||||
label={t('profile.fields.confirmPassword')}
|
label={t('profile.fields.confirmPassword')}
|
||||||
leftSection={<IconLock size={18} />}
|
leftSection={<IconLock size={18} />}
|
||||||
@@ -770,7 +812,7 @@ export function ProfilePage() {
|
|||||||
backgroundColor:
|
backgroundColor:
|
||||||
i <= score
|
i <= score
|
||||||
? `var(--mantine-color-${strengthColors[score]}-6)`
|
? `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,
|
Anchor,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
Card,
|
||||||
FileButton,
|
FileButton,
|
||||||
Group,
|
Group,
|
||||||
Loader,
|
Loader,
|
||||||
@@ -12,7 +13,6 @@ import {
|
|||||||
Paper,
|
Paper,
|
||||||
Select,
|
Select,
|
||||||
Stack,
|
Stack,
|
||||||
Table,
|
|
||||||
Tabs,
|
Tabs,
|
||||||
Text,
|
Text,
|
||||||
TextInput,
|
TextInput,
|
||||||
@@ -31,7 +31,8 @@ import {
|
|||||||
IconTrash,
|
IconTrash,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useState } from '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 {
|
import {
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
uploadDocument,
|
uploadDocument,
|
||||||
@@ -156,7 +157,8 @@ const EMPTY_SEA_SERVICE = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function SeaServiceTab() {
|
function SeaServiceTab() {
|
||||||
const { data: records, isLoading } = useGetMySeaServiceRecordsQuery();
|
const showDate = useDateDisplayer();
|
||||||
|
const { data: records, isLoading, refetch } = useGetMySeaServiceRecordsQuery();
|
||||||
const { data: seaTime } = useGetMySeaTimeQuery();
|
const { data: seaTime } = useGetMySeaTimeQuery();
|
||||||
const [createRecord, { isLoading: creating }] =
|
const [createRecord, { isLoading: creating }] =
|
||||||
useCreateSeaServiceRecordMutation();
|
useCreateSeaServiceRecordMutation();
|
||||||
@@ -237,7 +239,90 @@ function SeaServiceTab() {
|
|||||||
form.dischargeDate &&
|
form.dischargeDate &&
|
||||||
form.engagementDate < 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 (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -264,87 +349,20 @@ function SeaServiceTab() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={720}>
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead>
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>Vessel</Table.Th>
|
tableName="Sea service"
|
||||||
<Table.Th>Rank</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>From</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>To</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>Status</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th />
|
onPageSizeChange={setPageSize}
|
||||||
</Table.Tr>
|
isLoading={isLoading}
|
||||||
</Table.Thead>
|
refresh={refetch}
|
||||||
<Table.Tbody>
|
/>
|
||||||
{(records ?? []).map((record) => {
|
</Card>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -393,23 +411,23 @@ function SeaServiceTab() {
|
|||||||
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
onChange={(e) => setForm({ ...form, rank: e.target.value })}
|
||||||
/>
|
/>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
|
||||||
label="Engagement date"
|
label="Engagement date"
|
||||||
required
|
required
|
||||||
value={form.engagementDate}
|
value={form.engagementDate}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, engagementDate: e.target.value })
|
setForm({ ...form, engagementDate: val })
|
||||||
}
|
}
|
||||||
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
|
||||||
label="Discharge date"
|
label="Discharge date"
|
||||||
required
|
required
|
||||||
value={form.dischargeDate}
|
value={form.dischargeDate}
|
||||||
onChange={(e) =>
|
onChange={(val) =>
|
||||||
setForm({ ...form, dischargeDate: e.target.value })
|
setForm({ ...form, dischargeDate: val })
|
||||||
}
|
}
|
||||||
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Textarea
|
<Textarea
|
||||||
@@ -455,7 +473,8 @@ const EMPTY_MEDICAL = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
function MedicalTab() {
|
function MedicalTab() {
|
||||||
const { data: certificates, isLoading } = useGetMyMedicalCertificatesQuery();
|
const showDate = useDateDisplayer();
|
||||||
|
const { data: certificates, isLoading, refetch } = useGetMyMedicalCertificatesQuery();
|
||||||
const [createCertificate, { isLoading: creating }] =
|
const [createCertificate, { isLoading: creating }] =
|
||||||
useCreateMedicalCertificateMutation();
|
useCreateMedicalCertificateMutation();
|
||||||
const [updateCertificate, { isLoading: updating }] =
|
const [updateCertificate, { isLoading: updating }] =
|
||||||
@@ -530,7 +549,99 @@ function MedicalTab() {
|
|||||||
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
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 (
|
return (
|
||||||
<Stack>
|
<Stack>
|
||||||
@@ -550,103 +661,20 @@ function MedicalTab() {
|
|||||||
</Text>
|
</Text>
|
||||||
</Paper>
|
</Paper>
|
||||||
) : (
|
) : (
|
||||||
<Table.ScrollContainer minWidth={720}>
|
<Card withBorder padding={0}>
|
||||||
<Table striped highlightOnHover>
|
<AdvancedTable
|
||||||
<Table.Thead>
|
columns={columns}
|
||||||
<Table.Tr>
|
data={page.rows}
|
||||||
<Table.Th>Issuer</Table.Th>
|
tableName="Medical certificates"
|
||||||
<Table.Th>Issued</Table.Th>
|
itemCount={page.itemCount}
|
||||||
<Table.Th>Expires</Table.Th>
|
pageIndex={page.pageIndex}
|
||||||
<Table.Th>Fitness</Table.Th>
|
onPageChange={setPageIndex}
|
||||||
<Table.Th>Status</Table.Th>
|
pageSize={pageSize}
|
||||||
<Table.Th />
|
onPageSizeChange={setPageSize}
|
||||||
</Table.Tr>
|
isLoading={isLoading}
|
||||||
</Table.Thead>
|
refresh={refetch}
|
||||||
<Table.Tbody>
|
/>
|
||||||
{(certificates ?? []).map((certificate) => {
|
</Card>
|
||||||
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>
|
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Modal
|
<Modal
|
||||||
@@ -673,19 +701,19 @@ function MedicalTab() {
|
|||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Group grow>
|
<Group grow>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
|
||||||
label="Issue date"
|
label="Issue date"
|
||||||
required
|
required
|
||||||
value={form.issueDate}
|
value={form.issueDate}
|
||||||
onChange={(e) => setForm({ ...form, issueDate: e.target.value })}
|
onChange={(val) => setForm({ ...form, issueDate: val })}
|
||||||
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
<TextInput
|
<AmharicDatePicker
|
||||||
type="date"
|
|
||||||
label="Expiry date"
|
label="Expiry date"
|
||||||
required
|
required
|
||||||
value={form.expiryDate}
|
value={form.expiryDate}
|
||||||
onChange={(e) => setForm({ ...form, expiryDate: e.target.value })}
|
onChange={(val) => setForm({ ...form, expiryDate: val })}
|
||||||
|
dateFormat="date"
|
||||||
/>
|
/>
|
||||||
</Group>
|
</Group>
|
||||||
<Select
|
<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">
|
<Group gap="xs">
|
||||||
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
|
<IconAnchor size={18} color="var(--mantine-color-blue-5)" />
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Ownership transfer, amendment and duplicate-certificate services
|
Amendment and duplicate-certificate services are coming in a
|
||||||
are coming in a later release.
|
later release.
|
||||||
</Text>
|
</Text>
|
||||||
</Group>
|
</Group>
|
||||||
</Paper>
|
</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,
|
IconInfoCircle,
|
||||||
} from '@tabler/icons-react';
|
} from '@tabler/icons-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
|
import { useTranslation } from 'react-i18next';
|
||||||
import {
|
import {
|
||||||
STATUS_COLORS,
|
STATUS_COLORS,
|
||||||
STATUS_LABELS,
|
STATUS_LABELS,
|
||||||
TERMINAL_STATUSES,
|
TERMINAL_STATUSES,
|
||||||
extractErrorMessage,
|
extractErrorMessage,
|
||||||
|
useLocalized,
|
||||||
useGetCertificateUrlMutation,
|
useGetCertificateUrlMutation,
|
||||||
useGetMyApplicationsQuery,
|
useGetMyApplicationsQuery,
|
||||||
useGetMyLicensesQuery,
|
useGetMyLicensesQuery,
|
||||||
} from '@ema-platform/api';
|
} from '@ema-platform/api';
|
||||||
import { notify } from '@ema-platform/ui';
|
import { notify } from '@ema-platform/ui';
|
||||||
|
import { useDateDisplayer } from '@ema-platform/shared';
|
||||||
|
|
||||||
const WAIVER_TYPE_KEYS = ['PRE_WAIVER', 'POST_WAIVER'];
|
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).
|
* verifiable references (US-WAV-010).
|
||||||
*/
|
*/
|
||||||
export function WaiverPage() {
|
export function WaiverPage() {
|
||||||
|
const { t } = useTranslation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
const { data: applications, isLoading } = useGetMyApplicationsQuery();
|
||||||
const { data: licenses } = useGetMyLicensesQuery();
|
const { data: licenses } = useGetMyLicensesQuery();
|
||||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||||
|
const showDate = useDateDisplayer();
|
||||||
|
const localized = useLocalized();
|
||||||
|
|
||||||
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
const waiverApplications = (applications?.items ?? []).filter((app) =>
|
||||||
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
WAIVER_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||||
@@ -59,7 +65,7 @@ export function WaiverPage() {
|
|||||||
const result = await getCertificateUrl(licenseId).unwrap();
|
const result = await getCertificateUrl(licenseId).unwrap();
|
||||||
window.open(result.url, '_blank', 'noopener');
|
window.open(result.url, '_blank', 'noopener');
|
||||||
} catch (error) {
|
} 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 (
|
return (
|
||||||
<Stack maw={900} mx="auto">
|
<Stack maw={900} mx="auto">
|
||||||
<div>
|
<div>
|
||||||
<Title order={2}>Maritime Waiver</Title>
|
<Title order={2}>{t('waiver.title', 'Maritime Waiver')}</Title>
|
||||||
<Text size="sm" c="dimmed">
|
<Text size="sm" c="dimmed">
|
||||||
Apply for a waiver when Ethiopian Shipping & Logistics cannot
|
{t(
|
||||||
carry your shipment. Approval issues the bank waiver letter.
|
'waiver.subtitle',
|
||||||
|
'Apply for a waiver when Ethiopian Shipping & Logistics cannot carry your shipment. Approval issues the bank waiver letter.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||||
<Card withBorder radius="md" p="lg">
|
<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">
|
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||||
The cargo has not yet arrived. Applying before arrival avoids the
|
{t(
|
||||||
post-waiver penalty.
|
'waiver.preWaiver.body',
|
||||||
|
'The cargo has not yet arrived. Applying before arrival avoids the post-waiver penalty.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
rightSection={<IconArrowRight size={16} />}
|
rightSection={<IconArrowRight size={16} />}
|
||||||
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
onClick={() => navigate('/licensing/PRE_WAIVER/apply')}
|
||||||
>
|
>
|
||||||
Apply for a pre-waiver
|
{t('waiver.preWaiver.apply', 'Apply for a pre-waiver')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Card withBorder radius="md" p="lg">
|
<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">
|
<Text size="sm" c="dimmed" mt={4} mb="md">
|
||||||
The cargo has already arrived. Granted once per shipment, and only
|
{t(
|
||||||
against a settled penalty with the receipt attached.
|
'waiver.postWaiver.body',
|
||||||
|
'The cargo has already arrived. Granted once per shipment, and only against a settled penalty with the receipt attached.',
|
||||||
|
)}
|
||||||
</Text>
|
</Text>
|
||||||
<Button
|
<Button
|
||||||
variant="light"
|
variant="light"
|
||||||
rightSection={<IconArrowRight size={16} />}
|
rightSection={<IconArrowRight size={16} />}
|
||||||
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
onClick={() => navigate('/licensing/POST_WAIVER/apply')}
|
||||||
>
|
>
|
||||||
Apply for a post-waiver
|
{t('waiver.postWaiver.apply', 'Apply for a post-waiver')}
|
||||||
</Button>
|
</Button>
|
||||||
</Card>
|
</Card>
|
||||||
</SimpleGrid>
|
</SimpleGrid>
|
||||||
|
|
||||||
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
<Alert color="blue" icon={<IconInfoCircle size={16} />}>
|
||||||
Each waiver covers one shipment, identified by its bill of lading. A
|
{t(
|
||||||
second application quoting the same bill of lading is refused while the
|
'waiver.oneShipmentNotice',
|
||||||
first is live.
|
'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>
|
</Alert>
|
||||||
|
|
||||||
{inFlight.length > 0 && (
|
{inFlight.length > 0 && (
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Title order={4}>Applications in progress</Title>
|
<Title order={4}>{t('waiver.inProgress', 'Applications in progress')}</Title>
|
||||||
{inFlight.map((app) => (
|
{inFlight.map((app) => (
|
||||||
<Card key={app.id} withBorder radius="md" p="md">
|
<Card key={app.id} withBorder radius="md" p="md">
|
||||||
<Group justify="space-between">
|
<Group justify="space-between">
|
||||||
<div>
|
<div>
|
||||||
<Text fw={600}>{app.applicationNumber}</Text>
|
<Text fw={600}>{app.applicationNumber}</Text>
|
||||||
<Text size="xs" c="dimmed">
|
<Text size="xs" c="dimmed">
|
||||||
{app.licenseType?.name?.en}
|
{localized(app.licenseType?.name)}
|
||||||
</Text>
|
</Text>
|
||||||
</div>
|
</div>
|
||||||
<Group>
|
<Group>
|
||||||
<Badge color={STATUS_COLORS[app.status]}>
|
<Badge color={STATUS_COLORS[app.status]}>
|
||||||
{STATUS_LABELS[app.status]}
|
{t(`applications.status.${app.status}`, STATUS_LABELS[app.status])}
|
||||||
</Badge>
|
</Badge>
|
||||||
<Button
|
<Button
|
||||||
size="compact-sm"
|
size="compact-sm"
|
||||||
@@ -144,8 +157,8 @@ export function WaiverPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||||
? 'Continue'
|
? t('applications.actions.continue', 'Continue')
|
||||||
: 'View'}
|
: t('applications.actions.view', 'View')}
|
||||||
</Button>
|
</Button>
|
||||||
</Group>
|
</Group>
|
||||||
</Group>
|
</Group>
|
||||||
@@ -155,11 +168,11 @@ export function WaiverPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
<Stack gap="xs">
|
<Stack gap="xs">
|
||||||
<Title order={4}>Issued waiver letters</Title>
|
<Title order={4}>{t('waiver.issuedLetters', 'Issued waiver letters')}</Title>
|
||||||
{letters.length === 0 ? (
|
{letters.length === 0 ? (
|
||||||
<Card withBorder radius="md" p="lg">
|
<Card withBorder radius="md" p="lg">
|
||||||
<Text size="sm" c="dimmed" ta="center">
|
<Text size="sm" c="dimmed" ta="center">
|
||||||
No waiver letters issued yet.
|
{t('waiver.emptyLetters', 'No waiver letters issued yet.')}
|
||||||
</Text>
|
</Text>
|
||||||
</Card>
|
</Card>
|
||||||
) : (
|
) : (
|
||||||
@@ -167,10 +180,10 @@ export function WaiverPage() {
|
|||||||
<Table striped>
|
<Table striped>
|
||||||
<Table.Thead>
|
<Table.Thead>
|
||||||
<Table.Tr>
|
<Table.Tr>
|
||||||
<Table.Th>Reference</Table.Th>
|
<Table.Th>{t('waiver.columns.reference', 'Reference')}</Table.Th>
|
||||||
<Table.Th>Kind</Table.Th>
|
<Table.Th>{t('waiver.columns.kind', 'Kind')}</Table.Th>
|
||||||
<Table.Th>Issued</Table.Th>
|
<Table.Th>{t('waiver.columns.issued', 'Issued')}</Table.Th>
|
||||||
<Table.Th>Status</Table.Th>
|
<Table.Th>{t('waiver.columns.status', 'Status')}</Table.Th>
|
||||||
<Table.Th />
|
<Table.Th />
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
</Table.Thead>
|
</Table.Thead>
|
||||||
@@ -182,15 +195,15 @@ export function WaiverPage() {
|
|||||||
{license.certificateNumber}
|
{license.certificateNumber}
|
||||||
</Text>
|
</Text>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>{license.licenseType?.name?.en ?? '—'}</Table.Td>
|
<Table.Td>{localized(license.licenseType?.name) || '—'}</Table.Td>
|
||||||
<Table.Td>{license.issueDate?.slice(0, 10)}</Table.Td>
|
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
<Badge
|
<Badge
|
||||||
size="sm"
|
size="sm"
|
||||||
variant="light"
|
variant="light"
|
||||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||||
>
|
>
|
||||||
{license.status}
|
{t(`waiver.licenseStatus.${license.status}`, license.status)}
|
||||||
</Badge>
|
</Badge>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
<Table.Td>
|
<Table.Td>
|
||||||
@@ -200,7 +213,7 @@ export function WaiverPage() {
|
|||||||
leftSection={<IconFileText size={13} />}
|
leftSection={<IconFileText size={13} />}
|
||||||
onClick={() => download(license.id)}
|
onClick={() => download(license.id)}
|
||||||
>
|
>
|
||||||
Letter
|
{t('waiver.letter', 'Letter')}
|
||||||
</Button>
|
</Button>
|
||||||
</Table.Td>
|
</Table.Td>
|
||||||
</Table.Tr>
|
</Table.Tr>
|
||||||
|
|||||||
@@ -7,6 +7,17 @@ export const am: Translations = {
|
|||||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
msg: {
|
||||||
|
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||||
|
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||||
|
validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።',
|
||||||
|
authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።',
|
||||||
|
permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።',
|
||||||
|
notFoundError: 'የተጠየቀው ንጥል አልተገኘም።',
|
||||||
|
fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።',
|
||||||
|
networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።',
|
||||||
|
},
|
||||||
|
|
||||||
language: {
|
language: {
|
||||||
label: 'ቋንቋ',
|
label: 'ቋንቋ',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
@@ -42,12 +53,18 @@ export const am: Translations = {
|
|||||||
myApplication: 'ማመልከቻዬ',
|
myApplication: 'ማመልከቻዬ',
|
||||||
certificates: 'የምስክር ወረቀቶች',
|
certificates: 'የምስክር ወረቀቶች',
|
||||||
endorsements: 'ማረጋገጫዎች',
|
endorsements: 'ማረጋገጫዎች',
|
||||||
|
vesselRegistrations: 'የመርከብ ምዝገባ',
|
||||||
|
vesselTransfers: 'የመርከብ ባለቤትነት ዝውውር',
|
||||||
documents: 'ሰነዶቼ',
|
documents: 'ሰነዶቼ',
|
||||||
notifications: 'ማሳወቂያዎች',
|
notifications: 'ማሳወቂያዎች',
|
||||||
profile: 'መገለጫ',
|
profile: 'መገለጫ',
|
||||||
support: 'እገዛና ድጋፍ',
|
support: 'እገዛና ድጋፍ',
|
||||||
collapseSidebar: 'ሰብስብ',
|
collapseSidebar: 'ሰብስብ',
|
||||||
expandSidebar: 'ዘርጋ',
|
expandSidebar: 'ዘርጋ',
|
||||||
|
sectionSeafarerServices: 'የመርከበኞች አገልግሎቶች',
|
||||||
|
sectionVesselServices: 'የመርከብ አገልግሎቶች',
|
||||||
|
sectionLogisticsLicensing: 'ሎጂስቲክስና ፈቃድ',
|
||||||
|
sectionAccountManagement: 'የመለያ አስተዳደር',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
@@ -77,6 +94,8 @@ export const am: Translations = {
|
|||||||
learnMore: 'ተጨማሪ ይወቁ',
|
learnMore: 'ተጨማሪ ይወቁ',
|
||||||
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
|
toggleTheme: 'ብርሃን / ጨለማ ገጽታ ቀይር',
|
||||||
welcome: 'እንኳን ደህና መጡ',
|
welcome: 'እንኳን ደህና መጡ',
|
||||||
|
switchCalendar: 'የቀን መቁጠሪያ ዓይነት ቀይር',
|
||||||
|
time: 'ሰዓት',
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
@@ -92,6 +111,85 @@ export const am: Translations = {
|
|||||||
quickActions: 'ፈጣን ድርጊቶች',
|
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. */
|
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||||
profileFields: {
|
profileFields: {
|
||||||
firstName: 'የመጀመሪያ ስም',
|
firstName: 'የመጀመሪያ ስም',
|
||||||
@@ -130,6 +228,8 @@ export const am: Translations = {
|
|||||||
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
title_other: 'ከመቀጠልዎ በፊት {{count}} ተጨማሪ መረጃዎች እንፈልጋለን',
|
||||||
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
addDetails: 'እነዚህን መረጃዎች ጨምር',
|
||||||
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
viewProfile: 'ሙሉ መገለጫ ይመልከቱ',
|
||||||
|
seafarerReason: 'የባህረኞች ምዝገባ የሚዘጋጀው ከመገለጫዎ ነው — እነዚህ መረጃዎች ራሱ ይሞላሉ።',
|
||||||
|
seafarerRedirect: 'የባህረኛ ምዝገባ ለማድረግ መገለጫዎን ያጠናቅቁ። የሚያስፈልጉ፡ {{fields}}',
|
||||||
},
|
},
|
||||||
|
|
||||||
profileSections: {
|
profileSections: {
|
||||||
@@ -216,13 +316,18 @@ export const am: Translations = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
country: {
|
||||||
|
select: 'አገር ይምረጡ',
|
||||||
|
notFound: 'ምንም አገር አልተገኘም',
|
||||||
|
},
|
||||||
|
|
||||||
location: {
|
location: {
|
||||||
select: 'ይምረጡ...',
|
select: 'ይምረጡ...',
|
||||||
noOptions: 'ምንም አማራጮች አልተገኙም',
|
noOptions: 'ምንም አማራጮች አልተገኙም',
|
||||||
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
|
noLocationsAvailable: 'ምንም አካባቢዎች የሉም',
|
||||||
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
|
chooseFirst: 'መጀመሪያ አካባቢ ይምረጡ',
|
||||||
subLocation: 'ንዑስ አካባቢ',
|
|
||||||
loading: 'አካባቢዎች በመጫን ላይ...',
|
loading: 'አካባቢዎች በመጫን ላይ...',
|
||||||
|
loadFailed: 'አካባቢዎችን መጫን አልተቻለም',
|
||||||
},
|
},
|
||||||
|
|
||||||
support: {
|
support: {
|
||||||
@@ -247,4 +352,38 @@ export const am: Translations = {
|
|||||||
a4: 'በማመልከቻው ላይ "እርምጃ ያስፈልጋል" የሚል ማስታወሻ ያያሉ። ግምገማውን ለመቀጠል የተጠየቀውን ሰነድ ወይም ዝርዝር ያቅርቡ።',
|
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',
|
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: {
|
language: {
|
||||||
label: 'Language',
|
label: 'Language',
|
||||||
en: 'English',
|
en: 'English',
|
||||||
@@ -40,12 +51,18 @@ export const en = {
|
|||||||
waiver: 'Waiver',
|
waiver: 'Waiver',
|
||||||
certificates: 'Certificates',
|
certificates: 'Certificates',
|
||||||
endorsements: 'Endorsements',
|
endorsements: 'Endorsements',
|
||||||
|
vesselRegistrations: 'Vessel Registration',
|
||||||
|
vesselTransfers: 'Vessel Transfers',
|
||||||
documents: 'My Documents',
|
documents: 'My Documents',
|
||||||
notifications: 'Notifications',
|
notifications: 'Notifications',
|
||||||
profile: 'Profile',
|
profile: 'Profile',
|
||||||
support: 'Help & Support',
|
support: 'Help & Support',
|
||||||
collapseSidebar: 'Collapse',
|
collapseSidebar: 'Collapse',
|
||||||
expandSidebar: 'Expand sidebar',
|
expandSidebar: 'Expand sidebar',
|
||||||
|
sectionSeafarerServices: 'Seafarer Services',
|
||||||
|
sectionVesselServices: 'Vessel Services',
|
||||||
|
sectionLogisticsLicensing: 'Logistics and Licensing',
|
||||||
|
sectionAccountManagement: 'Account Management',
|
||||||
},
|
},
|
||||||
|
|
||||||
common: {
|
common: {
|
||||||
@@ -75,6 +92,8 @@ export const en = {
|
|||||||
learnMore: 'Learn more',
|
learnMore: 'Learn more',
|
||||||
toggleTheme: 'Toggle light / dark mode',
|
toggleTheme: 'Toggle light / dark mode',
|
||||||
welcome: 'Welcome',
|
welcome: 'Welcome',
|
||||||
|
switchCalendar: 'Switch calendar type',
|
||||||
|
time: 'Time',
|
||||||
},
|
},
|
||||||
|
|
||||||
auth: {
|
auth: {
|
||||||
@@ -90,6 +109,85 @@ export const en = {
|
|||||||
quickActions: 'Quick actions',
|
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. */
|
/** Field labels shared by the completeness nudge and the requirement gates. */
|
||||||
profileFields: {
|
profileFields: {
|
||||||
firstName: 'First name',
|
firstName: 'First name',
|
||||||
@@ -128,6 +226,9 @@ export const en = {
|
|||||||
title_other: 'We need {{count}} more details before you continue',
|
title_other: 'We need {{count}} more details before you continue',
|
||||||
addDetails: 'Add these details',
|
addDetails: 'Add these details',
|
||||||
viewProfile: 'View full profile',
|
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: {
|
profileSections: {
|
||||||
@@ -214,13 +315,18 @@ export const en = {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
|
country: {
|
||||||
|
select: 'Select a country',
|
||||||
|
notFound: 'No countries found',
|
||||||
|
},
|
||||||
|
|
||||||
location: {
|
location: {
|
||||||
select: 'Select...',
|
select: 'Select...',
|
||||||
noOptions: 'No options found',
|
noOptions: 'No options found',
|
||||||
noLocationsAvailable: 'No locations available',
|
noLocationsAvailable: 'No locations available',
|
||||||
chooseFirst: 'Choose a location first',
|
chooseFirst: 'Choose a location first',
|
||||||
subLocation: 'Sub-location',
|
|
||||||
loading: 'Loading locations...',
|
loading: 'Loading locations...',
|
||||||
|
loadFailed: 'Could not load locations',
|
||||||
},
|
},
|
||||||
|
|
||||||
support: {
|
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.',
|
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;
|
export type Translations = typeof en;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { AppShell } from '@mantine/core';
|
import { AppShell } from "@mantine/core";
|
||||||
import { useDisclosure } from '@mantine/hooks';
|
import { useDisclosure } from "@mantine/hooks";
|
||||||
import {
|
import {
|
||||||
IconArrowsExchange,
|
IconArrowsExchange,
|
||||||
IconBell,
|
IconBell,
|
||||||
@@ -14,15 +14,19 @@ import {
|
|||||||
IconShip,
|
IconShip,
|
||||||
IconTruck,
|
IconTruck,
|
||||||
IconUserCircle,
|
IconUserCircle,
|
||||||
} from '@tabler/icons-react';
|
} from "@tabler/icons-react";
|
||||||
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
|
import { Outlet, useLocation, useNavigate } from "react-router-dom";
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useMemo } from "react";
|
||||||
import { useDispatch } from 'react-redux';
|
import { useTranslation } from "react-i18next";
|
||||||
import { notify, AppHeader, AppSidebar } from '@ema-platform/ui';
|
import { useDispatch } from "react-redux";
|
||||||
import type { NavItem } from '@ema-platform/ui';
|
import { notify, AppHeader, AppSidebar } from "@ema-platform/ui";
|
||||||
import { BrandMark, logout } from '@ema-platform/auth';
|
import type { NavItem } from "@ema-platform/ui";
|
||||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
import { BrandMark, logout } from "@ema-platform/auth";
|
||||||
import { useAppSelector } from '../store/hooks';
|
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 };
|
type PortalNavItem = NavItem & { i18nKey: string };
|
||||||
|
|
||||||
@@ -35,51 +39,75 @@ type PortalNavItem = NavItem & { i18nKey: string };
|
|||||||
const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
const NAV_SECTIONS: { label?: string; items: PortalNavItem[] }[] = [
|
||||||
{
|
{
|
||||||
items: [
|
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: [
|
items: [
|
||||||
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
|
{ to: '/licensing/applications', label: 'My Applications', i18nKey: 'nav.myApplications', icon: IconTruck },
|
||||||
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
{ to: '/waiver', label: 'Waiver', i18nKey: 'nav.waiver', icon: IconShieldOff },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'nav.groupSeafarer',
|
label: "nav.groupSeafarer",
|
||||||
items: [
|
items: [
|
||||||
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
|
{ to: '/seafarer-registration', label: 'Seafarer Registration', i18nKey: 'nav.seafarerRegistration', icon: IconList },
|
||||||
{ to: '/seafarer/records', label: 'My Sea Records', i18nKey: 'nav.seaRecords', 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: '/seaman-book', label: 'Seaman Book', i18nKey: 'nav.myApplication', icon: IconSend, soon: true },
|
||||||
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
{ to: '/certificates', label: 'Certificates', i18nKey: 'nav.certificates', icon: IconShieldCheck },
|
||||||
{ to: '/exams', label: 'Examinations', i18nKey: 'nav.exams', icon: IconList },
|
{ 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: [
|
items: [
|
||||||
{ to: '/vessel-registration', label: 'Vessel Registration', i18nKey: 'nav.vesselRegistration', icon: IconShip },
|
{ 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: [
|
items: [
|
||||||
{ to: '/documents', label: 'My Documents', i18nKey: 'nav.documents', icon: IconFolderOpen },
|
{
|
||||||
{ to: '/profile', label: 'Profile', i18nKey: 'nav.profile', icon: IconUserCircle },
|
to: "/documents",
|
||||||
{ to: '/support', label: 'Help & Support', i18nKey: 'nav.support', icon: IconHeadset },
|
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 }> = {
|
const PAGE_META: Record<string, { i18nKey: string }> = {
|
||||||
'/dashboard': { i18nKey: 'nav.dashboard' },
|
'/dashboard': { i18nKey: 'nav.dashboard' },
|
||||||
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
'/vessel-registration-dashboard': { i18nKey: 'nav.vesselRegistrationDashboard' },
|
||||||
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
'/vessel-registration': { i18nKey: 'nav.vesselRegistration' },
|
||||||
'/vessel-registration/transfer': { i18nKey: 'nav.ownershipTransfer' },
|
'/vessel-ownership-transfer': { i18nKey: 'nav.ownershipTransfer' },
|
||||||
'/licensing/applications': { i18nKey: 'nav.myApplications' },
|
'/licensing/applications': { i18nKey: 'nav.myApplications' },
|
||||||
'/waiver': { i18nKey: 'nav.waiver' },
|
'/waiver': { i18nKey: 'nav.waiver' },
|
||||||
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
|
'/seafarer-registration': { i18nKey: 'nav.seafarerRegistration' },
|
||||||
@@ -102,14 +130,32 @@ export function PortalLayout() {
|
|||||||
const user = useAppSelector((state) => state.auth.user);
|
const user = useAppSelector((state) => state.auth.user);
|
||||||
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
const [navOpened, { toggle: toggleNav, close: closeNav }] = useDisclosure();
|
||||||
const [sidebarCollapsed, { toggle: toggleSidebar }] = useDisclosure(false);
|
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
|
// Breadcrumb trail
|
||||||
const segments = location.pathname.split('/').filter(Boolean);
|
const segments = location.pathname.split("/").filter(Boolean);
|
||||||
const crumbs = [
|
const crumbs = [
|
||||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||||
...segments
|
...segments
|
||||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
.map((_, i) => "/" + segments.slice(0, i + 1).join("/"))
|
||||||
.filter((path) => PAGE_META[path] && path !== '/dashboard')
|
.filter((path) => PAGE_META[path] && path !== "/dashboard")
|
||||||
.map((path) => ({ label: t(PAGE_META[path].i18nKey), path })),
|
.map((path) => ({ label: t(PAGE_META[path].i18nKey), path })),
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -126,28 +172,34 @@ export function PortalLayout() {
|
|||||||
|
|
||||||
const handleLogout = () => {
|
const handleLogout = () => {
|
||||||
dispatch(logout());
|
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
|
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 (
|
return (
|
||||||
<AppShell
|
<AppShell
|
||||||
header={{ height: 74 }}
|
header={{ height: 74 }}
|
||||||
navbar={{
|
navbar={{
|
||||||
width: sidebarCollapsed ? 72 : 264,
|
width: sidebarCollapsed ? 72 : 264,
|
||||||
breakpoint: 'sm',
|
breakpoint: "sm",
|
||||||
collapsed: { mobile: !navOpened },
|
collapsed: { mobile: !navOpened },
|
||||||
}}
|
}}
|
||||||
padding="lg"
|
padding="lg"
|
||||||
>
|
>
|
||||||
<AppShell.Header
|
<AppShell.Header
|
||||||
style={{
|
style={{
|
||||||
background: 'var(--mantine-color-body)',
|
background: "var(--mantine-color-body)",
|
||||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppHeader
|
<AppHeader
|
||||||
@@ -157,32 +209,31 @@ export function PortalLayout() {
|
|||||||
breadcrumbs={crumbs}
|
breadcrumbs={crumbs}
|
||||||
onNavigate={navigate}
|
onNavigate={navigate}
|
||||||
onLogout={handleLogout}
|
onLogout={handleLogout}
|
||||||
userName={displayName || t('app.name')}
|
userName={displayName || t("app.name")}
|
||||||
userInitials={initials}
|
userInitials={initials}
|
||||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||||
|
onNotificationsClick={() => navigate("/notifications")}
|
||||||
|
notificationCount={unseen?.count}
|
||||||
/>
|
/>
|
||||||
</AppShell.Header>
|
</AppShell.Header>
|
||||||
|
|
||||||
<AppShell.Navbar
|
<AppShell.Navbar
|
||||||
p={0}
|
p={0}
|
||||||
style={{
|
style={{
|
||||||
overflow: 'hidden',
|
overflow: "hidden",
|
||||||
transition: 'width 200ms ease',
|
transition: "width 200ms ease",
|
||||||
background: 'var(--mantine-color-body)',
|
background: "var(--mantine-color-body)",
|
||||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<AppSidebar
|
<AppSidebar
|
||||||
navItems={NAV_SECTIONS.map((section) => ({
|
navItems={sections}
|
||||||
label: section.label,
|
|
||||||
items: section.items.map(({ i18nKey, ...rest }) => ({ ...rest, label: t(i18nKey) })),
|
|
||||||
}))}
|
|
||||||
collapsed={sidebarCollapsed}
|
collapsed={sidebarCollapsed}
|
||||||
activePath={location.pathname}
|
activePath={location.pathname}
|
||||||
onToggleCollapse={toggleSidebar}
|
onToggleCollapse={toggleSidebar}
|
||||||
onNavigate={go}
|
onNavigate={go}
|
||||||
brandName={t('app.name')}
|
brandName={t("app.name")}
|
||||||
brandSubtitle={t('app.authority')}
|
brandSubtitle={t("app.authority")}
|
||||||
brandLogo={<BrandMark size={32} />}
|
brandLogo={<BrandMark size={32} />}
|
||||||
/>
|
/>
|
||||||
</AppShell.Navbar>
|
</AppShell.Navbar>
|
||||||
|
|||||||
@@ -1,74 +1,92 @@
|
|||||||
import { createBrowserRouter, Navigate } from 'react-router-dom';
|
import { createBrowserRouter, Navigate } from "react-router-dom";
|
||||||
import { I18nextProvider } from 'react-i18next';
|
import { I18nextProvider } from "react-i18next";
|
||||||
import { i18n } from './i18n/config';
|
import { i18n } from "./i18n/config";
|
||||||
import { PortalLayout } from './layouts/PortalLayout';
|
import { PortalLayout } from "./layouts/PortalLayout";
|
||||||
import { ProtectedRoute } from './components/ProtectedRoute';
|
import { ProtectedRoute } from "./components/ProtectedRoute";
|
||||||
|
|
||||||
// Auth (standalone pages, no portal chrome)
|
// 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
|
// Portal feature pages
|
||||||
import { DashboardPage } from './features/dashboard/pages/DashboardPage';
|
import { DashboardPage } from "./features/dashboard/pages/DashboardPage";
|
||||||
import { RequireOperations } from './features/onboarding/components/RequireOperations';
|
import { RequireOperations } from "./features/onboarding/components/RequireOperations";
|
||||||
import { OperationsOnboardingPage } from './features/onboarding/pages/OperationsOnboardingPage';
|
import { RequireSeafarerProfile } from "./features/profile/components/RequireSeafarerProfile";
|
||||||
import { ProfilePage } from './features/profile/pages/ProfilePage';
|
import { OperationsOnboardingPage } from "./features/onboarding/pages/OperationsOnboardingPage";
|
||||||
import { SupportPage } from './features/support/pages/SupportPage';
|
import { ProfilePage } from "./features/profile/pages/ProfilePage";
|
||||||
import { SeafarerRegistrationPage } from './features/seafarer/pages/SeafarerRegistrationPage';
|
import { SupportPage } from "./features/support/pages/SupportPage";
|
||||||
import { MySeaRecordsPage } from './features/seafarer/pages/MySeaRecordsPage';
|
import { SeafarerRegistrationPage } from "./features/seafarer/pages/SeafarerRegistrationPage";
|
||||||
import { VerifyCertificatePage } from './features/verify/pages/VerifyCertificatePage';
|
import { MySeaRecordsPage } from "./features/seafarer/pages/MySeaRecordsPage";
|
||||||
import { ExamsPage } from './features/exams/pages/ExamsPage';
|
import { VerifyCertificatePage } from "./features/verify/pages/VerifyCertificatePage";
|
||||||
|
import { ExamsPage } from "./features/exams/pages/ExamsPage";
|
||||||
|
|
||||||
// Phase 1 pages
|
// Phase 1 pages
|
||||||
import { DocumentVaultPage } from './features/documents/pages/DocumentVaultPage';
|
import { DocumentVaultPage } from "./features/documents/pages/DocumentVaultPage";
|
||||||
import { SeamanBookPage } from './features/seaman-book/pages/SeamanBookPage';
|
import { SeamanBookPage } from "./features/seaman-book/pages/SeamanBookPage";
|
||||||
import { SeamanBookApplicationPage } from './features/seaman-book/pages/SeamanBookApplicationPage';
|
import { SeamanBookApplicationPage } from "./features/seaman-book/pages/SeamanBookApplicationPage";
|
||||||
import { NotificationsPage } from './features/notifications/pages/NotificationsPage';
|
import { NotificationsPage } from "./features/notifications/pages/NotificationsPage";
|
||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
import { CertificatesPage } from './features/certificates/pages/CertificatesPage';
|
import { CertificatesPage } from "./features/certificates/pages/CertificatesPage";
|
||||||
import { CoCApplicationPage } from './features/certificates/pages/CoCApplicationPage';
|
import { CoCApplicationPage } from "./features/certificates/pages/CoCApplicationPage";
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
import { EndorsementPage } from './features/endorsement/pages/EndorsementPage';
|
import { EndorsementPage } from "./features/endorsement/pages/EndorsementPage";
|
||||||
import { VesselRegistrationPage } from './features/vessel-registration/pages/VesselRegistrationPage';
|
import { VesselRegistrationPage } from "./features/vessel-registration/pages/VesselRegistrationPage";
|
||||||
import { OwnershipTransferPage } from './features/vessel-registration/pages/OwnershipTransferPage';
|
import { VesselTransferPage } from "./features/vessel-registration/pages/VesselTransferPage";
|
||||||
import { MyApplicationsPage } from './features/licensing/pages/MyApplicationsPage';
|
import { MyApplicationsPage } from "./features/licensing/pages/MyApplicationsPage";
|
||||||
import { PaymentCheckPage } from './features/payments/pages/PaymentCheckPage';
|
import { PaymentCheckPage } from "./features/payments/pages/PaymentCheckPage";
|
||||||
import { PaymentSuccessPage } from './features/payments/pages/PaymentSuccessPage';
|
import { PaymentSuccessPage } from "./features/payments/pages/PaymentSuccessPage";
|
||||||
import { PaymentFailurePage } from './features/payments/pages/PaymentFailurePage';
|
import { PaymentFailurePage } from "./features/payments/pages/PaymentFailurePage";
|
||||||
import { LicenseApplicationPage } from './features/licensing/pages/LicenseApplicationPage';
|
import { LicenseApplicationPage } from "./features/licensing/pages/LicenseApplicationPage";
|
||||||
import { WaiverPage } from './features/waiver/pages/WaiverPage';
|
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([
|
export const router = createBrowserRouter([
|
||||||
// Public auth pages
|
// Public auth pages
|
||||||
{ path: '/login', element: <LoginPage /> },
|
{ path: "/login", element: <LoginPage /> },
|
||||||
{ path: '/signup', element: <SignupPage /> },
|
{ path: "/signup", element: <SignupPage /> },
|
||||||
|
|
||||||
// Public certificate verification — the target of every printed QR code.
|
// Public certificate verification — the target of every printed QR code.
|
||||||
// No auth: a verifier scanning a certificate has no portal account.
|
// No auth: a verifier scanning a certificate has no portal account.
|
||||||
{ path: '/verify', element: <VerifyCertificatePage /> },
|
{ path: "/verify", element: <VerifyCertificatePage /> },
|
||||||
{ path: '/verify/:code', element: <VerifyCertificatePage /> },
|
{ path: "/verify/:code", element: <VerifyCertificatePage /> },
|
||||||
|
|
||||||
// Completes the forgot-password flow; the reset message links here. The
|
// Completes the forgot-password flow; the reset message links here. The
|
||||||
// IAM package generates `/reset-password` links, `/set-password` is the
|
// IAM package generates `/reset-password` links, `/set-password` is the
|
||||||
// first-time-credential variant — one page serves both.
|
// first-time-credential variant — one page serves both.
|
||||||
{ path: '/set-password', element: <SetPasswordPage /> },
|
{ path: "/set-password", element: <SetPasswordPage /> },
|
||||||
{ path: '/reset-password', element: <SetPasswordPage /> },
|
{ path: "/reset-password", element: <SetPasswordPage /> },
|
||||||
|
|
||||||
// Protected auth pages
|
// Protected auth pages
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute><OTPVerificationPage /></ProtectedRoute>,
|
element: (
|
||||||
path: '/otp-verify',
|
<ProtectedRoute>
|
||||||
|
<OTPVerificationPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
path: "/otp-verify",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
element: <ProtectedRoute><ForgotPasswordPage /></ProtectedRoute>,
|
element: (
|
||||||
path: '/forgot-password',
|
<ProtectedRoute>
|
||||||
|
<ForgotPasswordPage />
|
||||||
|
</ProtectedRoute>
|
||||||
|
),
|
||||||
|
path: "/forgot-password",
|
||||||
},
|
},
|
||||||
// The two-step setup wizard is gone. Signing up lands on the dashboard, and
|
// 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,
|
// profile details are collected where they are actually needed: on /profile,
|
||||||
// via the dashboard nudge, or inline in an application flow. The path stays
|
// 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.
|
// 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.
|
// Portal — protected.
|
||||||
{
|
{
|
||||||
@@ -84,89 +102,204 @@ export const router = createBrowserRouter([
|
|||||||
</ProtectedRoute>
|
</ProtectedRoute>
|
||||||
),
|
),
|
||||||
children: [
|
children: [
|
||||||
{ path: '/', element: <Navigate to="/dashboard" replace /> },
|
{ path: "/", element: <Navigate to="/dashboard" replace /> },
|
||||||
{ path: '/dashboard', element: <DashboardPage /> },
|
{ path: "/dashboard", element: <DashboardPage /> },
|
||||||
{ path: '/onboarding/operations', element: <OperationsOnboardingPage /> },
|
{ path: "/onboarding/operations", element: <OperationsOnboardingPage /> },
|
||||||
|
|
||||||
// Config-driven licensing: one set of pages serves every licence type.
|
// Config-driven licensing: one set of pages serves every licence type.
|
||||||
{ path: '/licensing/applications', element: <MyApplicationsPage /> },
|
{ path: "/licensing/applications", element: <MyApplicationsPage /> },
|
||||||
|
|
||||||
// Telebirr returns the applicant to these.
|
// Telebirr returns the applicant to these.
|
||||||
{ path: '/payments/check', element: <PaymentCheckPage /> },
|
{ path: "/payments/check", element: <PaymentCheckPage /> },
|
||||||
{ path: '/payments/success', element: <PaymentSuccessPage /> },
|
{ path: "/payments/success", element: <PaymentSuccessPage /> },
|
||||||
{ path: '/payments/failure', element: <PaymentFailurePage /> },
|
{ path: "/payments/failure", element: <PaymentFailurePage /> },
|
||||||
{ path: '/licensing/:typeCode/apply', element: <LicenseApplicationPage /> },
|
|
||||||
{
|
{
|
||||||
path: '/licensing/:typeCode/applications/:applicationId',
|
path: "/licensing/:typeCode/apply",
|
||||||
|
element: (
|
||||||
|
<RequireSeafarerProfile>
|
||||||
|
<LicenseApplicationPage />
|
||||||
|
</RequireSeafarerProfile>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: "/licensing/:typeCode/applications/:applicationId",
|
||||||
element: <LicenseApplicationPage />,
|
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
|
// Seafarer
|
||||||
{ path: '/seafarer-registration', element: <SeafarerRegistrationPage /> },
|
{
|
||||||
{ path: '/seafarer/records', element: <MySeaRecordsPage /> },
|
path: "/seafarer-registration",
|
||||||
{ path: '/exams', element: <ExamsPage /> },
|
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 public-facing registry was a hardcoded mock and does not belong in
|
||||||
// the applicant portal; officers browse seafarers in the backoffice.
|
// the applicant portal; officers browse seafarers in the backoffice.
|
||||||
{ 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
|
// Phase 1
|
||||||
{ path: '/documents', element: <DocumentVaultPage /> },
|
{ path: "/documents", element: <DocumentVaultPage /> },
|
||||||
{ path: '/seaman-book', element: <SeamanBookPage /> },
|
{ path: "/seaman-book", element: <SeamanBookPage /> },
|
||||||
{ path: '/seaman-book/apply', element: <SeamanBookApplicationPage /> },
|
{ path: "/seaman-book/apply", element: <SeamanBookApplicationPage /> },
|
||||||
{ path: '/notifications', element: <NotificationsPage /> },
|
{ path: "/notifications", element: <NotificationsPage /> },
|
||||||
|
|
||||||
// Phase 2 — CoC / CoP
|
// Phase 2 — CoC / CoP
|
||||||
{ path: '/certificates', element: <CertificatesPage /> },
|
{ path: "/certificates", element: <CertificatesPage /> },
|
||||||
{ path: '/certificates/apply', element: <CoCApplicationPage /> },
|
{ path: "/certificates/apply", element: <CoCApplicationPage /> },
|
||||||
|
|
||||||
// Phase 3 — Endorsement
|
// Phase 3 — Endorsement
|
||||||
{ path: '/endorsements', element: <EndorsementPage /> },
|
{ path: "/endorsements", element: <EndorsementPage /> },
|
||||||
{ path: '/vessel-registration', element: <VesselRegistrationPage /> },
|
{ path: "/vessel-registration", element: <VesselRegistrationPage /> },
|
||||||
// The registration wizard is the config-driven licensing flow; the old
|
// The registration wizard is the config-driven licensing flow; the old
|
||||||
// standalone wizard posted to endpoints that never existed.
|
// standalone wizard posted to endpoints that never existed.
|
||||||
{ 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/apply",
|
||||||
{ path: '/vessel-registration/transfer', element: <OwnershipTransferPage /> },
|
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
|
// Legacy per-licence-type URLs. Each once had its own hand-written page
|
||||||
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
// that posted to a `/logistics-licenses/*` endpoint the API never had,
|
||||||
// and dropped every uploaded document on the floor. They are kept as
|
// and dropped every uploaded document on the floor. They are kept as
|
||||||
// redirects so old bookmarks land somewhere real; the config-driven
|
// redirects so old bookmarks land somewhere real; the config-driven
|
||||||
// wizard below serves every licence type from one place.
|
// 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: "/logistics-dashboard",
|
||||||
{ path: '/freight-forwarder-license/apply', element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" replace /> },
|
element: <Navigate to="/dashboard" 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: "/freight-forwarder-license",
|
||||||
{ path: '/shipping-agent-license/:id/renew', element: <Navigate to="/licensing/applications" replace /> },
|
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: "/freight-forwarder-license/apply",
|
||||||
{ path: '/joint-investment-license', element: <Navigate to="/licensing/applications" replace /> },
|
element: <Navigate to="/licensing/FREIGHT_FORWARDER/apply" 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: "/freight-forwarder-license/:id/renew",
|
||||||
{ path: '/mto-license/apply', element: <Navigate to="/licensing/MULTIMODAL_TRANSPORT_OPERATOR/apply" replace /> },
|
element: <Navigate to="/licensing/applications" replace />,
|
||||||
{ path: '/mto-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.
|
// Waiver has no backend yet, so it says so rather than pretending.
|
||||||
{ path: '/waiver', element: <WaiverPage /> },
|
{ path: "/waiver", element: <WaiverPage /> },
|
||||||
{ path: '/waiver/apply', element: <Navigate to="/waiver" replace /> },
|
{ 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
|
// General
|
||||||
{ path: '/profile', element: <ProfilePage /> },
|
{ path: "/profile", element: <ProfilePage /> },
|
||||||
{ path: '/support', element: <SupportPage /> },
|
{ path: "/support", element: <SupportPage /> },
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
|
|
||||||
// The separate vessel-owner login/portal was mock-only and called auth
|
// The separate vessel-owner login/portal was mock-only and called auth
|
||||||
// endpoints that never existed; vessel owners are ordinary portal users.
|
// endpoints that never existed; vessel owners are ordinary portal users.
|
||||||
{ path: '/vessel-owner/login', element: <Navigate to="/login" 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/register",
|
||||||
{ path: '/vessel-owner/registration', element: <Navigate to="/vessel-registration" replace /> },
|
element: <Navigate to="/signup" 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/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 { configureStore } from "@reduxjs/toolkit";
|
||||||
import { baseApi, configureTokenRefresh } from '@ema-platform/api';
|
import { baseApi, configureTokenRefresh } from "@ema-platform/api";
|
||||||
import {
|
import {
|
||||||
authReducer,
|
authReducer,
|
||||||
signupReducer,
|
signupReducer,
|
||||||
@@ -7,17 +7,23 @@ import {
|
|||||||
authStorage,
|
authStorage,
|
||||||
refreshAccessToken,
|
refreshAccessToken,
|
||||||
logout,
|
logout,
|
||||||
} from '@ema-platform/auth';
|
setToken,
|
||||||
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
} from "@ema-platform/auth";
|
||||||
|
import type { AuthUser, CurrentProfile } from "@ema-platform/auth";
|
||||||
|
|
||||||
configureAuthStorage('ema-portal');
|
configureAuthStorage("ema-portal", true);
|
||||||
|
|
||||||
const preloadedAuth = (() => {
|
const preloadedAuth = (() => {
|
||||||
const token = authStorage.getToken();
|
const token = authStorage.getToken();
|
||||||
const user = authStorage.getUser<AuthUser>();
|
const user = authStorage.getUser<AuthUser>();
|
||||||
const profile = authStorage.getProfile<CurrentProfile>();
|
const profile = authStorage.getProfile<CurrentProfile>();
|
||||||
if (token && user) {
|
if (token && user) {
|
||||||
return { token, user, isAuthenticated: true, currentProfile: profile ?? null };
|
return {
|
||||||
|
token,
|
||||||
|
user,
|
||||||
|
isAuthenticated: true,
|
||||||
|
currentProfile: profile ?? null,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
return undefined;
|
return undefined;
|
||||||
})();
|
})();
|
||||||
@@ -34,10 +40,17 @@ export const store = configureStore({
|
|||||||
});
|
});
|
||||||
|
|
||||||
configureTokenRefresh({
|
configureTokenRefresh({
|
||||||
onTokenExpired: refreshAccessToken,
|
onTokenExpired: async () => {
|
||||||
|
const token = await refreshAccessToken();
|
||||||
|
|
||||||
|
store.dispatch(setToken(token));
|
||||||
|
|
||||||
|
return token;
|
||||||
|
},
|
||||||
|
|
||||||
onAuthFailure: () => {
|
onAuthFailure: () => {
|
||||||
store.dispatch(logout());
|
store.dispatch(logout());
|
||||||
window.location.href = '/login';
|
window.location.href = "/login";
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -93,6 +93,22 @@ export const portalTheme = createTheme({
|
|||||||
Textarea: { defaultProps: { radius: 'md' } },
|
Textarea: { defaultProps: { radius: 'md' } },
|
||||||
Select: { defaultProps: { radius: 'md' } },
|
Select: { defaultProps: { radius: 'md' } },
|
||||||
PasswordInput: { defaultProps: { radius: 'md' } },
|
PasswordInput: { defaultProps: { radius: 'md' } },
|
||||||
|
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
|
||||||
|
// max-height it's handed unless scrollAreaComponent is set, so a modal
|
||||||
|
// taller than the viewport just gets clipped with no way to scroll it.
|
||||||
|
// Making the body the scrollport here fixes every Modal/Drawer at once.
|
||||||
|
Modal: {
|
||||||
|
styles: {
|
||||||
|
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
|
||||||
|
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
|
Drawer: {
|
||||||
|
styles: {
|
||||||
|
content: { display: 'flex', flexDirection: 'column' },
|
||||||
|
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
other: {
|
other: {
|
||||||
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { StrictMode } from 'react';
|
import { StrictMode } from 'react';
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from 'react-dom/client';
|
||||||
import '@mantine/core/styles.css';
|
import '@mantine/core/styles.css';
|
||||||
|
import '@mantine/dates/styles.css';
|
||||||
import '@mantine/notifications/styles.css';
|
import '@mantine/notifications/styles.css';
|
||||||
import './app/theme/portal.css';
|
import './app/theme/portal.css';
|
||||||
|
|
||||||
|
|||||||
@@ -2,3 +2,41 @@
|
|||||||
@tailwind base;
|
@tailwind base;
|
||||||
@tailwind components;
|
@tailwind components;
|
||||||
@tailwind utilities;
|
@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 { createApi } from "@reduxjs/toolkit/query/react";
|
||||||
import { baseQueryWithReauth } from './base-query-with-reauth';
|
import { baseQueryWithReauth } from "./base-query-with-reauth";
|
||||||
|
import { tagTypes } from "./tagTypes";
|
||||||
export const baseApi = createApi({
|
export const baseApi = createApi({
|
||||||
reducerPath: 'baseApi',
|
reducerPath: "baseApi",
|
||||||
baseQuery: baseQueryWithReauth,
|
baseQuery: baseQueryWithReauth,
|
||||||
tagTypes: ['Api'],
|
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
|
||||||
endpoints: () => ({}),
|
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.types';
|
||||||
export * from './licensing-api';
|
export * from './licensing-api';
|
||||||
export * from './licensing.helpers';
|
export * from './licensing.helpers';
|
||||||
|
export * from './use-localized';
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ import { resolveTokenFromStorage } from '../../session';
|
|||||||
import type {
|
import type {
|
||||||
Bilingual,
|
Bilingual,
|
||||||
FormSectionConfig,
|
FormSectionConfig,
|
||||||
|
LicenseApplication,
|
||||||
LicenseStatus,
|
LicenseStatus,
|
||||||
ValidationIssue,
|
ValidationIssue,
|
||||||
} from './licensing.types';
|
} from './licensing.types';
|
||||||
@@ -129,10 +130,34 @@ export const TERMINAL_STATUSES: LicenseStatus[] = [
|
|||||||
'REJECTED',
|
'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. */
|
/** Reads a bilingual value for the active language, falling back to English. */
|
||||||
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
export function localized(value: Bilingual | undefined, language = 'en'): string {
|
||||||
if (!value) return '';
|
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.
|
* instead of showing an empty page.
|
||||||
*/
|
*/
|
||||||
hasStaff?: boolean;
|
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[] {
|
): WizardStep[] {
|
||||||
const visible = [...sections]
|
const visible = [...sections]
|
||||||
@@ -237,7 +265,7 @@ export function buildWizardSteps(
|
|||||||
if (!group) {
|
if (!group) {
|
||||||
steps.push({
|
steps.push({
|
||||||
key: `section:${section.key}`,
|
key: `section:${section.key}`,
|
||||||
label: localized(section.title),
|
label: localized(section.title, options?.language),
|
||||||
kind: 'sections',
|
kind: 'sections',
|
||||||
sections: [section],
|
sections: [section],
|
||||||
});
|
});
|
||||||
@@ -292,6 +320,7 @@ export type FieldErrors = Record<string, string>;
|
|||||||
export function validateSections(
|
export function validateSections(
|
||||||
sections: FormSectionConfig[],
|
sections: FormSectionConfig[],
|
||||||
formData: Record<string, Record<string, unknown>>,
|
formData: Record<string, Record<string, unknown>>,
|
||||||
|
language = 'en',
|
||||||
): FieldErrors {
|
): FieldErrors {
|
||||||
const errors: FieldErrors = {};
|
const errors: FieldErrors = {};
|
||||||
|
|
||||||
@@ -315,8 +344,8 @@ export function validateSections(
|
|||||||
if (field.required && empty) {
|
if (field.required && empty) {
|
||||||
errors[`${section.key}.${field.key}`] =
|
errors[`${section.key}.${field.key}`] =
|
||||||
field.type === 'BOOLEAN'
|
field.type === 'BOOLEAN'
|
||||||
? `${localized(field.label)} must be accepted`
|
? `${localized(field.label, language)} must be accepted`
|
||||||
: `${localized(field.label)} is required`;
|
: `${localized(field.label, language)} is required`;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (empty) continue;
|
if (empty) continue;
|
||||||
|
|||||||
@@ -7,35 +7,35 @@ export type Bilingual = { en?: string; am?: string };
|
|||||||
* previously this lived in a backoffice mock page.
|
* previously this lived in a backoffice mock page.
|
||||||
*/
|
*/
|
||||||
export type LicenseStatus =
|
export type LicenseStatus =
|
||||||
| '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'
|
| "PAYMENT_CONFIRMED"
|
||||||
| 'CERTIFICATE_ISSUED'
|
| "CERTIFICATE_ISSUED"
|
||||||
| 'COMPLETED';
|
| "COMPLETED";
|
||||||
|
|
||||||
export type ApplicationKind = 'NEW' | 'RENEWAL';
|
export type ApplicationKind = "NEW" | "RENEWAL";
|
||||||
|
|
||||||
export type FormFieldType =
|
export type FormFieldType =
|
||||||
| 'TEXT'
|
| "TEXT"
|
||||||
| 'TEXTAREA'
|
| "TEXTAREA"
|
||||||
| 'NUMBER'
|
| "NUMBER"
|
||||||
| 'MONEY'
|
| "MONEY"
|
||||||
| 'DATE'
|
| "DATE"
|
||||||
| 'SELECT'
|
| "SELECT"
|
||||||
| 'BOOLEAN'
|
| "BOOLEAN"
|
||||||
| 'EMAIL'
|
| "EMAIL"
|
||||||
| 'PHONE'
|
| "PHONE"
|
||||||
| 'TIN';
|
| "TIN";
|
||||||
|
|
||||||
export interface FieldCondition {
|
export interface FieldCondition {
|
||||||
field: string;
|
field: string;
|
||||||
@@ -138,7 +138,7 @@ export interface DocumentRequirement {
|
|||||||
name: Bilingual;
|
name: Bilingual;
|
||||||
description?: Bilingual;
|
description?: Bilingual;
|
||||||
applicationKind: ApplicationKind;
|
applicationKind: ApplicationKind;
|
||||||
mode: 'ALWAYS' | 'CONDITIONAL' | 'OPTIONAL';
|
mode: "ALWAYS" | "CONDITIONAL" | "OPTIONAL";
|
||||||
conditionExpression?: FieldCondition & { previousDocExpired?: string };
|
conditionExpression?: FieldCondition & { previousDocExpired?: string };
|
||||||
allowedMimeTypes: string[];
|
allowedMimeTypes: string[];
|
||||||
maxSizeMb: number;
|
maxSizeMb: number;
|
||||||
@@ -245,7 +245,7 @@ export interface StatusHistoryEntry {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type RemarkTargetType = 'FORM_SECTION' | 'DOCUMENT' | 'STAFF';
|
export type RemarkTargetType = "FORM_SECTION" | "DOCUMENT" | "STAFF";
|
||||||
|
|
||||||
export interface ApplicationRemark {
|
export interface ApplicationRemark {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -276,8 +276,8 @@ export interface Inspection {
|
|||||||
scheduledDate: string | null;
|
scheduledDate: string | null;
|
||||||
conductedDate: string | null;
|
conductedDate: string | null;
|
||||||
location: string | null;
|
location: string | null;
|
||||||
status: 'SCHEDULED' | 'COMPLETED' | 'CANCELLED';
|
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
|
||||||
result: 'PASSED' | 'FAILED' | null;
|
result: "PASSED" | "FAILED" | null;
|
||||||
findings: string | null;
|
findings: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -303,17 +303,18 @@ export interface QueueFilter {
|
|||||||
submittedTo?: string;
|
submittedTo?: string;
|
||||||
overdue?: boolean;
|
overdue?: boolean;
|
||||||
sortBy?: QueueSortField;
|
sortBy?: QueueSortField;
|
||||||
sortDir?: 'ASC' | 'DESC';
|
sortDir?: "ASC" | "DESC";
|
||||||
take?: number;
|
take?: number;
|
||||||
skip?: number;
|
skip?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type QueueSortField =
|
export type QueueSortField =
|
||||||
| 'submittedAt'
|
| "submittedAt"
|
||||||
| 'applicationNumber'
|
| "applicationNumber"
|
||||||
| 'companyName'
|
| "companyName"
|
||||||
| 'status'
|
| "status"
|
||||||
| 'dueAt';
|
| "dueAt"
|
||||||
|
| "claimedAt";
|
||||||
|
|
||||||
/** Row counts behind the queue's saved-view tabs. */
|
/** Row counts behind the queue's saved-view tabs. */
|
||||||
export interface QueueCounts {
|
export interface QueueCounts {
|
||||||
@@ -325,7 +326,7 @@ export interface QueueCounts {
|
|||||||
all: number;
|
all: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
|
export type DocumentDecision = "ACCEPTED" | "REJECTED";
|
||||||
|
|
||||||
/** An officer's verdict on one uploaded document. */
|
/** An officer's verdict on one uploaded document. */
|
||||||
export interface DocumentReview {
|
export interface DocumentReview {
|
||||||
@@ -363,10 +364,10 @@ export interface ExportResult {
|
|||||||
truncated: boolean;
|
truncated: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
|
export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
|
||||||
|
|
||||||
export interface TemplatePageOptions {
|
export interface TemplatePageOptions {
|
||||||
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
|
format?: "A4" | "A5" | "Letter" | "Legal";
|
||||||
landscape?: boolean;
|
landscape?: boolean;
|
||||||
printBackground?: boolean;
|
printBackground?: boolean;
|
||||||
}
|
}
|
||||||
@@ -398,7 +399,7 @@ export interface Paginated<T> {
|
|||||||
|
|
||||||
/** Per-field problems returned by the server when a submission is incomplete. */
|
/** Per-field problems returned by the server when a submission is incomplete. */
|
||||||
export interface ValidationIssue {
|
export interface ValidationIssue {
|
||||||
kind: 'field' | 'document' | 'staff';
|
kind: "field" | "document" | "staff";
|
||||||
target: string;
|
target: string;
|
||||||
field?: string;
|
field?: string;
|
||||||
message: string;
|
message: string;
|
||||||
@@ -406,7 +407,7 @@ export interface ValidationIssue {
|
|||||||
|
|
||||||
/** What the browser must do to complete a payment. */
|
/** What the browser must do to complete a payment. */
|
||||||
export interface ClientAction {
|
export interface ClientAction {
|
||||||
type: 'REDIRECT' | 'LAUNCH_APP' | 'NONE';
|
type: "REDIRECT" | "LAUNCH_APP" | "NONE";
|
||||||
url?: string;
|
url?: string;
|
||||||
appId?: string;
|
appId?: string;
|
||||||
receiveCode?: string;
|
receiveCode?: string;
|
||||||
@@ -414,12 +415,7 @@ export interface ClientAction {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type PaymentStatus =
|
export type PaymentStatus =
|
||||||
| 'PENDING'
|
"PENDING" | "PROCESSING" | "PAID" | "FAILED" | "EXPIRED" | "CANCELLED";
|
||||||
| 'PROCESSING'
|
|
||||||
| 'PAID'
|
|
||||||
| 'FAILED'
|
|
||||||
| 'EXPIRED'
|
|
||||||
| 'CANCELLED';
|
|
||||||
|
|
||||||
export interface InitiatePaymentResult {
|
export interface InitiatePaymentResult {
|
||||||
paymentId: string;
|
paymentId: string;
|
||||||
@@ -461,7 +457,7 @@ export interface IssuedLicense {
|
|||||||
tinNumber: string | null;
|
tinNumber: string | null;
|
||||||
issueDate: string;
|
issueDate: string;
|
||||||
expiryDate: 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
|
* 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
|
* 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