mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor
Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical table component: kept its API plus teammate i18n/feature work, kept the folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all 27 tables, removed the parallel lib/table implementation, and fixed pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
12
README.md
12
README.md
@@ -37,8 +37,8 @@ emaui/
|
||||
```
|
||||
|
||||
### libs/api
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or localStorage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads from `localStorage` keys or `auth-token` cookie. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
|
||||
- `session/` — `resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
|
||||
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
|
||||
|
||||
### libs/ui
|
||||
@@ -55,10 +55,10 @@ emaui/
|
||||
|
||||
1. User submits the login form (LoginForm / LoginPage).
|
||||
2. The form calls the `login` RTK Query mutation (backoffice) or a plain `fetch` (portal).
|
||||
3. On success, `loginSuccess` action is dispatched → Redux `auth` slice stores `token` and `user`; `authStorage.setToken()` persists the token to `localStorage`.
|
||||
3. On success, `loginSuccess` action is dispatched → Redux `auth` slice stores `token` and `user`; `authStorage.setToken()` persists the token to a cookie (both apps call `configureAuthStorage(prefix, true)`).
|
||||
4. `baseApi`'s `prepareHeaders` reads the token via `resolveSessionContext(getState())` and attaches `Authorization: Bearer <token>` to every RTK Query request.
|
||||
5. `ProtectedRoute` checks `localStorage` for the token key on every navigation — if absent, redirects to `/login`.
|
||||
6. `logout` action clears Redux state and calls `authStorage.clear()` to remove all localStorage keys.
|
||||
5. `ProtectedRoute` checks `authStorage`/the token cookie on every navigation — if absent, redirects to `/login`.
|
||||
6. `logout` action clears Redux state and calls `authStorage.clear()` to remove all auth cookies.
|
||||
|
||||
---
|
||||
|
||||
@@ -90,6 +90,8 @@ npm run dev:all
|
||||
|---|---|---|---|
|
||||
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
|
||||
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
|
||||
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
|
||||
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
|
||||
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
|
||||
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
// Analytics API - Coming soon
|
||||
// test from claude
|
||||
@@ -0,0 +1 @@
|
||||
// Analytics types - Coming soon
|
||||
@@ -39,12 +39,13 @@ import {
|
||||
useGetLicenseTemplatesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetTemplateVariablesQuery,
|
||||
useLocalized,
|
||||
usePublishLicenseTemplateMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useUpdateLicenseTemplateMutation,
|
||||
type LicenseTemplate,
|
||||
} from '@ema-platform/api';
|
||||
import { EmptyState, ErrorState, PageHeader } from '@ema-platform/ui';
|
||||
import { EmptyState, ErrorState, ModalFooter, PageHeader } from '@ema-platform/ui';
|
||||
import { authStorage, usePermissions } from '@ema-platform/auth';
|
||||
import { PERMISSIONS } from '../../../layouts/nav-config';
|
||||
|
||||
@@ -70,6 +71,7 @@ const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
|
||||
*/
|
||||
export function CertificateDesignerPage() {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { can } = usePermissions();
|
||||
|
||||
const canEdit = can([PERMISSIONS.UPDATE_TEMPLATE]);
|
||||
@@ -226,7 +228,7 @@ export function CertificateDesignerPage() {
|
||||
label={t('designer.licenceType', 'Licence type')}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name?.en ?? type.key,
|
||||
label: localized(type.name) || type.key,
|
||||
}))}
|
||||
value={typeId}
|
||||
onChange={(value) => {
|
||||
@@ -529,7 +531,7 @@ export function CertificateDesignerPage() {
|
||||
'Starts from the live design, or the built-in layout if this type has none.',
|
||||
)}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setNewOpen(false)}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
@@ -550,7 +552,7 @@ export function CertificateDesignerPage() {
|
||||
>
|
||||
{t('designer.create', 'Create')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
@@ -1,29 +1,29 @@
|
||||
import { ActionIcon, Group } from '@mantine/core';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Certification } from '../../types/certification';
|
||||
|
||||
export function certificationColumnActions(
|
||||
export function certificationActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (cert: Certification) => void;
|
||||
onDelete: (cert: Certification) => void;
|
||||
},
|
||||
): AdvancedTableAction<Certification>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('certification.update'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('certification.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
): AdvancedColumn<Certification> {
|
||||
return {
|
||||
header: '',
|
||||
label: t('certification.columns.actions', 'Actions'),
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(row.original)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(row.original)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,29 +1,26 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Certification } from '../../types/certification';
|
||||
|
||||
export function certificationColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
): AdvancedTableColumn<Certification>[] {
|
||||
): AdvancedColumn<Certification>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('certification.columns.name'),
|
||||
render: (cert) => <Text fz="sm" fw={500}>{cert.name[locale]}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm" fw={500}>{row.original.name[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('certification.columns.description'),
|
||||
render: (cert) => <Text fz="sm" lineClamp={2} maw={250}>{cert.description[locale]}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm" lineClamp={2} maw={250}>{row.original.description[locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('certification.columns.status'),
|
||||
render: (cert) => (
|
||||
<Badge size="sm" variant="light" color={cert.isActive ? 'teal' : 'gray'}>
|
||||
{cert.isActive ? t('certification.status.active') : t('certification.status.inactive')}
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -8,17 +8,13 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationColumnActions } from './actions';
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -26,6 +22,8 @@ import {
|
||||
useDeleteCertificationMutation,
|
||||
} from '../../api/certification-api';
|
||||
import type { Certification } from '../../types/certification';
|
||||
import { certificationColumns } from './columns';
|
||||
import { certificationActionsColumn } from './actions';
|
||||
|
||||
function CertificationForm({
|
||||
editing,
|
||||
@@ -54,27 +52,29 @@ function CertificationForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t('certification.update') : t('certification.add')} size="md">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput label={t('certification.form.nameEn')} placeholder={t('certification.form.nameEnPlaceholder')} value={nameEn} onChange={(e) => setNameEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('certification.form.nameAm')} placeholder={t('certification.form.nameAmPlaceholder')} value={nameAm} onChange={(e) => setNameAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('certification.form.descEn')} placeholder={t('certification.form.descEnPlaceholder')} value={descEn} onChange={(e) => setDescEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('certification.form.descAm')} placeholder={t('certification.form.descAmPlaceholder')} value={descAm} onChange={(e) => setDescAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('certification.update') : t('certification.create')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data, isLoading, isError, refetch } = useGetCertificationsQuery();
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data, isFetching, isError, refetch } = useGetCertificationsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
const [deleteCert] = useDeleteCertificationMutation();
|
||||
@@ -103,8 +103,8 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -115,14 +115,23 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('certification.loadError')} />;
|
||||
|
||||
const columns = [
|
||||
...certificationColumns(t, locale),
|
||||
certificationActionsColumn(t, {
|
||||
onEdit: (cert) => { setEditing(cert); setShowForm(true); },
|
||||
onDelete: (cert) => { setDeleteTarget(cert); openDelete(); },
|
||||
}),
|
||||
];
|
||||
|
||||
const page = paginate(certifications);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -146,26 +155,28 @@ export function CertificationPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={certificationColumns(t, locale)}
|
||||
data={certifications}
|
||||
rowKey={(cert) => cert.id}
|
||||
actions={certificationColumnActions(t, {
|
||||
onEdit: (cert) => { setEditing(cert); setShowForm(true); },
|
||||
onDelete: (cert) => { setDeleteTarget(cert); openDelete(); },
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('certification.noItems')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('certification.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('certification.noItems')}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('certification.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('certification.deleteConfirmText', { name: deleteTarget?.name?.[locale] })}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('certification.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('certification.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,38 +1,40 @@
|
||||
import { baseApi } from '@ema-platform/api';
|
||||
import { baseApi } from "@ema-platform/api";
|
||||
import type {
|
||||
Organization,
|
||||
Profession,
|
||||
ListResponse,
|
||||
CreateProfessionPayload,
|
||||
UpdateProfessionPayload,
|
||||
} from '../types/configuration';
|
||||
} from "../types/configuration";
|
||||
|
||||
const configurationApi = baseApi.injectEndpoints({
|
||||
endpoints: (builder) => ({
|
||||
getOrganizations: builder.query<ListResponse<Organization>, void>({
|
||||
query: () => '/organizations',
|
||||
providesTags: ['Api'],
|
||||
query: () => "/organizations",
|
||||
providesTags: ["Api"],
|
||||
}),
|
||||
|
||||
getProfessions: builder.query<ListResponse<Profession>, void>({
|
||||
query: () => '/professions',
|
||||
providesTags: ['Api'],
|
||||
getProfessions: builder.query<ListResponse<Profession>, string>({
|
||||
query: (params) => ({
|
||||
url: `/professions?q=${encodeURIComponent(params)}`,
|
||||
}),
|
||||
providesTags: ["Api", "backOfficeApi", "ProfessionApi"],
|
||||
}),
|
||||
createProfession: builder.mutation<Profession, CreateProfessionPayload>({
|
||||
query: (body) => ({ url: '/professions', method: 'POST', body }),
|
||||
invalidatesTags: ['Api'],
|
||||
query: (body) => ({ url: "/professions", method: "POST", body }),
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
updateProfession: builder.mutation<Profession, UpdateProfessionPayload>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/professions/${id}`,
|
||||
method: 'PUT',
|
||||
method: "PUT",
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: ['Api'],
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
deleteProfession: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/professions/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: ['Api'],
|
||||
query: (id) => ({ url: `/professions/${id}`, method: "DELETE" }),
|
||||
invalidatesTags: ["Api"],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: true,
|
||||
|
||||
@@ -1,29 +1,34 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Profession } from '../../types/configuration';
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash } from "@tabler/icons-react";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Profession } from "../../types/configuration";
|
||||
|
||||
export function professionColumnActions(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (prof: Profession) => void;
|
||||
onDelete: (prof: Profession) => void;
|
||||
},
|
||||
): AdvancedTableAction<Profession>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('configuration.edit'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('configuration.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
export function professionActionsColumn(handlers: {
|
||||
onEdit: (prof: Profession) => void;
|
||||
onDelete: (prof: Profession) => void;
|
||||
}): AdvancedColumn<Profession> {
|
||||
return {
|
||||
header: "actions",
|
||||
size: 90,
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,30 +1,30 @@
|
||||
import { Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Profession } from '../../types/configuration';
|
||||
import { Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Profession } from "../../types/configuration";
|
||||
|
||||
export function professionColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
locale: "en" | "am",
|
||||
getDeptName: (deptId: string) => string,
|
||||
): AdvancedTableColumn<Profession>[] {
|
||||
): AdvancedColumn<Profession>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: t('configuration.name'),
|
||||
render: (prof) => prof.name[locale],
|
||||
header: t("configuration.name"),
|
||||
cell: ({ row }) => row.original.name[locale],
|
||||
enabled: true,
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('configuration.description'),
|
||||
render: (prof) => (
|
||||
<Text size="sm" lineClamp={2} maw={200}>{prof.description[locale]}</Text>
|
||||
header: t("configuration.description"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" lineClamp={2} maw={200}>
|
||||
{row.original.description[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: t('configuration.department'),
|
||||
render: (prof) => getDeptName(prof.departmentId),
|
||||
header: t("configuration.department"),
|
||||
cell: ({ row }) => getDeptName(row.original.departmentId),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useState, useEffect, useCallback } from "react";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -10,28 +10,39 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
Select,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { professionColumns } from './columns';
|
||||
import { professionColumnActions } from './actions';
|
||||
import { LocationPage } from '../../../location/pages/LocationPage';
|
||||
import { CertificationPage } from '../../../certification/pages/CertificationPage';
|
||||
} from "@mantine/core";
|
||||
import { useForm } from "@mantine/form";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import {
|
||||
IconPlus,
|
||||
IconBriefcase,
|
||||
IconMap,
|
||||
IconCertificate,
|
||||
IconInfoCircle,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
notify,
|
||||
useErrorHandler,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
ModalFooter,
|
||||
} from "@ema-platform/ui";
|
||||
import { LocationPage } from "../../../location/pages/LocationPage";
|
||||
import { CertificationPage } from "../../../certification/pages/CertificationPage";
|
||||
import {
|
||||
useGetOrganizationsQuery,
|
||||
useGetProfessionsQuery,
|
||||
useCreateProfessionMutation,
|
||||
useUpdateProfessionMutation,
|
||||
useDeleteProfessionMutation,
|
||||
} from '../../api/configuration-api';
|
||||
import type { Profession } from '../../types/configuration';
|
||||
} from "../../api/configuration-api";
|
||||
import type { Profession } from "../../types/configuration";
|
||||
import { professionColumns } from "./columns";
|
||||
import { professionActionsColumn } from "./actions";
|
||||
|
||||
interface ProfFormValues {
|
||||
nameEn: string;
|
||||
@@ -49,14 +60,27 @@ interface ProfFormProps {
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCancel }: ProfFormProps) {
|
||||
function ProfessionForm({
|
||||
editingProf,
|
||||
deptOptions,
|
||||
isSubmitting,
|
||||
onSubmit,
|
||||
onCancel,
|
||||
}: ProfFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const form = useForm<ProfFormValues>({
|
||||
initialValues: { nameEn: '', nameAm: '', descEn: '', descAm: '', departmentId: '' },
|
||||
initialValues: {
|
||||
nameEn: "",
|
||||
nameAm: "",
|
||||
descEn: "",
|
||||
descAm: "",
|
||||
departmentId: "",
|
||||
},
|
||||
validate: {
|
||||
nameEn: (v) => (!v ? t('configuration.validation.nameEnRequired') : null),
|
||||
nameAm: (v) => (!v ? t('configuration.validation.nameAmRequired') : null),
|
||||
departmentId: (v) => (!v ? t('configuration.validation.departmentRequired') : null),
|
||||
nameEn: (v) => (!v ? t("configuration.validation.nameEnRequired") : null),
|
||||
nameAm: (v) => (!v ? t("configuration.validation.nameAmRequired") : null),
|
||||
departmentId: (v) =>
|
||||
!v ? t("configuration.validation.departmentRequired") : null,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -65,90 +89,125 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa
|
||||
form.setValues({
|
||||
nameEn: editingProf.name.en,
|
||||
nameAm: editingProf.name.am,
|
||||
descEn: editingProf.description.en ?? '',
|
||||
descAm: editingProf.description.am ?? '',
|
||||
descEn: editingProf.description.en ?? "",
|
||||
descAm: editingProf.description.am ?? "",
|
||||
departmentId: editingProf.departmentId,
|
||||
});
|
||||
}
|
||||
}, [editingProf]);
|
||||
|
||||
const handleSubmit = form.onSubmit((values) => onSubmit(values, !!editingProf));
|
||||
const handleSubmit = form.onSubmit((values) =>
|
||||
onSubmit(values, !!editingProf),
|
||||
);
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal
|
||||
opened
|
||||
onClose={onCancel}
|
||||
title={
|
||||
editingProf
|
||||
? t("configuration.update")
|
||||
: t("configuration.addProfession")
|
||||
}
|
||||
size="md"
|
||||
>
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<TextInput
|
||||
label={t('configuration.nameEn')}
|
||||
label={t("configuration.nameEn")}
|
||||
placeholder="English name"
|
||||
{...form.getInputProps('nameEn')}
|
||||
{...form.getInputProps("nameEn")}
|
||||
size="sm"
|
||||
/>
|
||||
<TextInput
|
||||
label={t('configuration.nameAm')}
|
||||
label={t("configuration.nameAm")}
|
||||
placeholder="የአማርኛ ስም"
|
||||
{...form.getInputProps('nameAm')}
|
||||
{...form.getInputProps("nameAm")}
|
||||
size="sm"
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descEn')}
|
||||
label={t("configuration.descEn")}
|
||||
placeholder="English description"
|
||||
{...form.getInputProps('descEn')}
|
||||
{...form.getInputProps("descEn")}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t('configuration.descAm')}
|
||||
label={t("configuration.descAm")}
|
||||
placeholder="የአማርኛ መግለጫ"
|
||||
{...form.getInputProps('descAm')}
|
||||
{...form.getInputProps("descAm")}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Select
|
||||
label={t('configuration.department')}
|
||||
placeholder={t('configuration.selectDepartment')}
|
||||
label={t("configuration.department")}
|
||||
placeholder={t("configuration.selectDepartment")}
|
||||
data={deptOptions}
|
||||
{...form.getInputProps('departmentId')}
|
||||
{...form.getInputProps("departmentId")}
|
||||
size="sm"
|
||||
searchable
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t('configuration.cancel')}
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editingProf ? t('configuration.update') : t('configuration.create')}
|
||||
{editingProf
|
||||
? t("configuration.update")
|
||||
: t("configuration.create")}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
function ProfessionTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: deptRes } = useGetOrganizationsQuery();
|
||||
const { data: profRes, isLoading, isError, refetch } = useGetProfessionsQuery();
|
||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] = useUpdateProfessionMutation();
|
||||
const { pageIndex, setPageIndex, setQ, pageSize, setPageSize, skip, take } =
|
||||
useServerTable({
|
||||
pageSize: 10,
|
||||
});
|
||||
const {
|
||||
data: profRes,
|
||||
isLoading,
|
||||
isFetching,
|
||||
isError,
|
||||
refetch,
|
||||
} = useGetProfessionsQuery(
|
||||
`skip:${skip},take:${take},orderBy:createdAt:DESC`,
|
||||
);
|
||||
const [createProfession, { isLoading: isCreating }] =
|
||||
useCreateProfessionMutation();
|
||||
const [updateProfession, { isLoading: isUpdating }] =
|
||||
useUpdateProfessionMutation();
|
||||
const [deleteProfession] = useDeleteProfessionMutation();
|
||||
|
||||
const departments = Array.isArray(deptRes) ? deptRes : (deptRes?.items ?? []);
|
||||
// Server now paginates, so the previous client-side `isActive` filter is
|
||||
// dropped (it would hide rows outside just this page). Restore it via a
|
||||
// server-side `q` filter once the backend field name is confirmed.
|
||||
const professions = profRes?.items ?? [];
|
||||
const totalCount = profRes?.total ?? profRes?.count ?? professions.length;
|
||||
|
||||
const [editingProf, setEditingProf] = useState<Profession | null>(null);
|
||||
const [showProfForm, setShowProfForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Profession | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const deptOptions = departments.filter((d) => d?.status?.toLowerCase() === 'active').map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name?.[locale] ?? d.name ?? '',
|
||||
}));
|
||||
const deptOptions = departments
|
||||
.filter((d) => d?.status?.toLowerCase() === "active")
|
||||
.map((d) => ({
|
||||
value: d.id,
|
||||
label: d.name?.[locale] ?? d.name ?? "",
|
||||
}));
|
||||
|
||||
const resetProfForm = useCallback(() => {
|
||||
setEditingProf(null);
|
||||
@@ -160,67 +219,100 @@ function ProfessionTab() {
|
||||
setShowProfForm(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteProf = useCallback((prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
}, [openDelete]);
|
||||
const handleDeleteProf = useCallback(
|
||||
(prof: Profession) => {
|
||||
setDeleteTarget(prof);
|
||||
openDelete();
|
||||
},
|
||||
[openDelete],
|
||||
);
|
||||
|
||||
const confirmDeleteProf = useCallback(async () => {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteProfession(deleteTarget.id).unwrap();
|
||||
notify.success(t('configuration.deleted'));
|
||||
notify.success(t("configuration.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [deleteTarget, deleteProfession, closeDelete, t]);
|
||||
}, [deleteTarget, deleteProfession, closeDelete, handleError]);
|
||||
|
||||
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
const handleProfSubmit = useCallback(
|
||||
async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
const description = { en: values.descEn, am: values.descAm };
|
||||
|
||||
try {
|
||||
if (editingProf) {
|
||||
await updateProfession({
|
||||
id: editingProf.id,
|
||||
name,
|
||||
description,
|
||||
departmentId: values.departmentId,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.updated'));
|
||||
} else {
|
||||
await createProfession({
|
||||
departmentId: values.departmentId,
|
||||
name,
|
||||
description,
|
||||
}).unwrap();
|
||||
notify.success(t('configuration.created'));
|
||||
try {
|
||||
if (editingProf) {
|
||||
await updateProfession({
|
||||
id: editingProf.id,
|
||||
name,
|
||||
description,
|
||||
departmentId: values.departmentId,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.updated"));
|
||||
} else {
|
||||
await createProfession({
|
||||
departmentId: values.departmentId,
|
||||
name,
|
||||
description,
|
||||
}).unwrap();
|
||||
notify.success(t("configuration.created"));
|
||||
}
|
||||
resetProfForm();
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
resetProfForm();
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
}
|
||||
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
|
||||
},
|
||||
[
|
||||
editingProf,
|
||||
createProfession,
|
||||
updateProfession,
|
||||
resetProfForm,
|
||||
handleError,
|
||||
],
|
||||
);
|
||||
|
||||
const getDeptName = useCallback((deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
|
||||
}, [departments, locale]);
|
||||
const getDeptName = useCallback(
|
||||
(deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? "-") : "-";
|
||||
},
|
||||
[departments, locale],
|
||||
);
|
||||
|
||||
const columns = [
|
||||
...professionColumns(t, locale, getDeptName),
|
||||
professionActionsColumn({
|
||||
onEdit: handleEditProf,
|
||||
onDelete: handleDeleteProf,
|
||||
}),
|
||||
];
|
||||
|
||||
if (isLoading) {
|
||||
return <Center py="xl"><Loader /></Center>;
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('configuration.error')} />;
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("configuration.error")}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Group justify="space-between" align="flex-end" mb="md">
|
||||
<Title order={2}>{t('configuration.professionsList')}</Title>
|
||||
<Title order={2}>{t("configuration.professionsList")}</Title>
|
||||
{!showProfForm && (
|
||||
<Button
|
||||
variant="light"
|
||||
@@ -228,7 +320,7 @@ function ProfessionTab() {
|
||||
onClick={() => setShowProfForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t('configuration.addProfession')}
|
||||
{t("configuration.addProfession")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -244,25 +336,39 @@ function ProfessionTab() {
|
||||
)}
|
||||
|
||||
<AdvancedTable
|
||||
columns={professionColumns(t, locale, getDeptName)}
|
||||
data={professions.filter((p) => p.isActive)}
|
||||
rowKey={(prof) => prof.id}
|
||||
actions={professionColumnActions(t, {
|
||||
onEdit: handleEditProf,
|
||||
onDelete: handleDeleteProf,
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('configuration.noProfessions')}
|
||||
columns={columns}
|
||||
data={professions}
|
||||
tableName={t("configuration.professionsList")}
|
||||
itemCount={totalCount}
|
||||
pageIndex={pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
onSearchChange={setQ}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("configuration.noProfessions")}
|
||||
/>
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('configuration.confirmDelete')} size="sm">
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("configuration.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
{t('configuration.deleteConfirmText', { name: deleteTarget?.name?.[locale] ?? '' })}
|
||||
{t("configuration.deleteConfirmText", {
|
||||
name: deleteTarget?.name?.[locale] ?? "",
|
||||
})}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('configuration.cancel')}</Button>
|
||||
<Button color="red" onClick={confirmDeleteProf} size="sm">{t('configuration.delete')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("configuration.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={confirmDeleteProf} size="sm">
|
||||
{t("configuration.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
@@ -273,18 +379,24 @@ export function ConfigurationPage() {
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Title order={2}>{t('configuration.title')}</Title>
|
||||
<Title order={2}>{t("configuration.title")}</Title>
|
||||
|
||||
<Tabs defaultValue="professions">
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="professions" leftSection={<IconBriefcase size={16} />}>
|
||||
{t('configuration.professions')}
|
||||
<Tabs.Tab
|
||||
value="professions"
|
||||
leftSection={<IconBriefcase size={16} />}
|
||||
>
|
||||
{t("configuration.professions")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="locations" leftSection={<IconMap size={16} />}>
|
||||
{t('location.title')}
|
||||
{t("location.title")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="certifications" leftSection={<IconCertificate size={16} />}>
|
||||
{t('certification.title')}
|
||||
<Tabs.Tab
|
||||
value="certifications"
|
||||
leftSection={<IconCertificate size={16} />}
|
||||
>
|
||||
{t("certification.title")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
|
||||
@@ -28,7 +28,8 @@ export interface Profession {
|
||||
}
|
||||
|
||||
export interface ListResponse<T> {
|
||||
count: number;
|
||||
count?: number;
|
||||
total?: number;
|
||||
items: T[];
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -7,27 +7,24 @@ import {
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export const dashboardQueueColumns: AdvancedTableColumn<LicenseApplication>[] = [
|
||||
export const dashboardQueueColumns: AdvancedColumn<LicenseApplication>[] = [
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: 'Number',
|
||||
render: (app) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: 'Company',
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[app.status as LicenseStatus]}>
|
||||
{STATUS_LABELS[app.status as LicenseStatus]}
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[row.original.status as LicenseStatus]}>
|
||||
{STATUS_LABELS[row.original.status as LicenseStatus]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -11,7 +11,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { useGetAssignedToMeQuery, useGetQueueQuery } from '@ema-platform/api';
|
||||
import { AdvancedTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import { dashboardQueueColumns } from './columns';
|
||||
|
||||
/**
|
||||
@@ -25,6 +25,7 @@ export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
@@ -37,6 +38,7 @@ export function DashboardPage() {
|
||||
const unclaimed = queue.data?.items ?? [];
|
||||
const inProgress = mine.data?.items ?? [];
|
||||
const all = [...unclaimed, ...inProgress];
|
||||
const paged = table.paginate(unclaimed.slice(0, 8));
|
||||
|
||||
const stats = [
|
||||
{ label: 'Awaiting claim', value: unclaimed.length, color: 'blue' },
|
||||
@@ -90,12 +92,16 @@ export function DashboardPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
tableName="Awaiting claim"
|
||||
columns={dashboardQueueColumns}
|
||||
data={unclaimed.slice(0, 8)}
|
||||
rowKey={(app) => app.id}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
onRowClick={() => navigate('/licence-review')}
|
||||
onRefresh={queue.refetch}
|
||||
emptyTitle="Nothing waiting to be claimed."
|
||||
refresh={queue.refetch}
|
||||
emptyText="Nothing waiting to be claimed."
|
||||
/>
|
||||
</Card>
|
||||
</Container>
|
||||
|
||||
@@ -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;
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconUserCheck } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { AttendanceStatus, ExamRegistration } from '../../types/exam';
|
||||
|
||||
const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
@@ -25,68 +25,64 @@ export const candidateName = (registration: ExamRegistration) =>
|
||||
export function examCandidateColumns(
|
||||
t: TFunction,
|
||||
handlers: { onRecord: (registration: ExamRegistration) => void },
|
||||
): AdvancedTableColumn<ExamRegistration>[] {
|
||||
): AdvancedColumn<ExamRegistration>[] {
|
||||
return [
|
||||
{
|
||||
key: 'admission',
|
||||
header: t('exam.candidates.admission'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
{row.original.admissionNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: t('exam.candidates.name'),
|
||||
render: (registration) => <Text fz="sm">{candidateName(registration)}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm">{candidateName(row.original)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'attempt',
|
||||
header: t('exam.candidates.attempt'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: registration.attemptNumber })
|
||||
{row.original.kind === 'RETAKE'
|
||||
? t('exam.candidates.retake', { n: row.original.attemptNumber })
|
||||
: t('exam.candidates.firstSitting')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: t('exam.candidates.attendance'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.attendance.${registration.attendanceStatus}`)}
|
||||
{t(`exam.attendance.${row.original.attendanceStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('exam.candidates.remark'),
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="xs" c="dimmed" maw={220} lineClamp={2}>
|
||||
{registration.attendanceRemark ?? '—'}
|
||||
{row.original.attendanceRemark ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'record',
|
||||
header: '',
|
||||
render: (registration) => (
|
||||
label: t('exam.candidates.record'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconUserCheck size={12} />}
|
||||
onClick={() => handlers.onRecord(registration)}
|
||||
onClick={() => handlers.onRecord(row.original)}
|
||||
>
|
||||
{t('exam.candidates.record')}
|
||||
</Button>
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamRegistrationsQuery,
|
||||
@@ -46,6 +46,7 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
const [target, setTarget] = useState<ExamRegistration | null>(null);
|
||||
const [status, setStatus] = useState<AttendanceStatus>('PRESENT');
|
||||
const [remark, setRemark] = useState('');
|
||||
const table = useServerTable();
|
||||
|
||||
const startRecording = (registration: ExamRegistration) => {
|
||||
setTarget(registration);
|
||||
@@ -81,6 +82,8 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
// register; the endpoint refuses them and there is nothing to show.
|
||||
if (isError) return null;
|
||||
|
||||
const paged = table.paginate(registrations ?? []);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">
|
||||
@@ -92,10 +95,14 @@ export function ExamCandidatesPanel({ examId }: { examId: string }) {
|
||||
</Alert>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
tableName={t('exam.candidates.section')}
|
||||
columns={examCandidateColumns(t, { onRecord: startRecording })}
|
||||
data={registrations ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
onRefresh={refetch}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { ExamIncident, ExamIncidentStatus } from '../../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
@@ -13,75 +13,72 @@ const STATUS_COLOR: Record<ExamIncidentStatus, string> = {
|
||||
|
||||
export function examIncidentColumns(
|
||||
t: TFunction,
|
||||
showDate: (date: string | null | undefined) => string,
|
||||
handlers: { onResolve: (incident: ExamIncident) => void },
|
||||
): AdvancedTableColumn<ExamIncident>[] {
|
||||
): AdvancedColumn<ExamIncident>[] {
|
||||
return [
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.incidents.type'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="orange">
|
||||
{t(`exam.incidentType.${incident.type}`)}
|
||||
{t(`exam.incidentType.${row.original.type}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'candidate',
|
||||
header: t('exam.incidents.candidate'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="xs">
|
||||
{incident.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
|
||||
{row.original.registration?.admissionNumber ?? t('exam.incidents.wholeRoom')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
header: t('exam.incidents.description'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text fz="xs" maw={260} lineClamp={2}>
|
||||
{incident.description}
|
||||
{row.original.description}
|
||||
</Text>
|
||||
{incident.resolution && (
|
||||
{row.original.resolution && (
|
||||
<Text fz="xs" c="dimmed" maw={260} lineClamp={2}>
|
||||
⤷ {incident.resolution}
|
||||
⤷ {row.original.resolution}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'occurred',
|
||||
header: t('exam.incidents.occurred'),
|
||||
render: (incident) => <Text fz="xs">{incident.occurredAt?.slice(0, 10)}</Text>,
|
||||
cell: ({ row }) => <Text fz="xs">{showDate(row.original.occurredAt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.incidents.status'),
|
||||
render: (incident) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[incident.status] ?? 'gray'}
|
||||
color={STATUS_COLOR[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{t(`exam.incidentStatus.${incident.status}`)}
|
||||
{t(`exam.incidentStatus.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'resolve',
|
||||
header: '',
|
||||
render: (incident) =>
|
||||
(incident.status === 'OPEN' || incident.status === 'UNDER_REVIEW') && (
|
||||
label: t('exam.incidents.resolve'),
|
||||
align: 'right',
|
||||
cell: ({ row }) =>
|
||||
row.original.status === 'OPEN' || row.original.status === 'UNDER_REVIEW' ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconAlertTriangle size={12} />}
|
||||
onClick={() => handlers.onResolve(incident)}
|
||||
onClick={() => handlers.onResolve(row.original)}
|
||||
>
|
||||
{t('exam.incidents.resolve')}
|
||||
</Button>
|
||||
),
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -13,7 +13,8 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamIncidentsQuery,
|
||||
@@ -39,7 +40,9 @@ const TYPES: ExamIncidentType[] = [
|
||||
*/
|
||||
export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: incidents, isError, refetch } = useGetExamIncidentsQuery(examId);
|
||||
const table = useServerTable();
|
||||
const { data: registrations } = useGetExamRegistrationsQuery(examId);
|
||||
const [recordIncident, { isLoading: isFiling }] = useRecordIncidentMutation();
|
||||
const [resolveIncident, { isLoading: isResolving }] = useResolveIncidentMutation();
|
||||
@@ -113,6 +116,8 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
// endpoint refuses them, so there is nothing to render.
|
||||
if (isError) return null;
|
||||
|
||||
const paged = table.paginate(incidents ?? []);
|
||||
|
||||
return (
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
@@ -133,11 +138,15 @@ export function ExamIncidentsPanel({ examId }: { examId: string }) {
|
||||
{t('exam.incidents.none')}
|
||||
</Alert>
|
||||
) : (
|
||||
<AdvancedTable
|
||||
columns={examIncidentColumns(t, { onResolve: startResolve })}
|
||||
data={incidents ?? []}
|
||||
rowKey={(incident) => incident.id}
|
||||
onRefresh={refetch}
|
||||
<AdvancedTable<ExamIncident>
|
||||
tableName={t('exam.incidents.section')}
|
||||
columns={examIncidentColumns(t, showDate, { onResolve: startResolve })}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={refetch}
|
||||
/>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { Dispatch, SetStateAction, useState } from "react";
|
||||
import {
|
||||
Paper,
|
||||
Group,
|
||||
@@ -10,16 +10,19 @@ import {
|
||||
Checkbox,
|
||||
Box,
|
||||
Button,
|
||||
} from '@mantine/core';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import type { QuestionBrief } from '../types/exam';
|
||||
} from "@mantine/core";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { IconSearch } from "@tabler/icons-react";
|
||||
import type { QuestionBrief } from "../types/exam";
|
||||
|
||||
type actionTypes = "add" | "remove";
|
||||
|
||||
interface QuestionAssignerProps {
|
||||
available: QuestionBrief[];
|
||||
assigned: QuestionBrief[];
|
||||
onChange: (assigned: QuestionBrief[]) => void;
|
||||
mode?: 'manual' | 'random';
|
||||
mode?: "manual" | "random";
|
||||
actions?: Dispatch<SetStateAction<actionTypes | undefined>>;
|
||||
}
|
||||
|
||||
function QuestionList({
|
||||
@@ -38,11 +41,13 @@ function QuestionList({
|
||||
label: string;
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const placeholder = t('exam.assigner.search');
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const placeholder = t("exam.assigner.search");
|
||||
return (
|
||||
<Box style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>{label} ({items.length})</Text>
|
||||
<Text fz="xs" fw={600} c="dimmed" mb={4}>
|
||||
{label} ({items.length})
|
||||
</Text>
|
||||
<Paper withBorder radius="md">
|
||||
<Group p="sm" pb={0}>
|
||||
<TextInput
|
||||
@@ -57,7 +62,9 @@ function QuestionList({
|
||||
<ScrollArea h={280} p="sm" pt="xs">
|
||||
<Stack gap={4}>
|
||||
{items.length === 0 && (
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">{t('exam.assigner.noQuestions')}</Text>
|
||||
<Text fz="xs" c="dimmed" ta="center" py="xl">
|
||||
{t("exam.assigner.noQuestions")}
|
||||
</Text>
|
||||
)}
|
||||
{items.map((q) => (
|
||||
<Paper
|
||||
@@ -66,19 +73,37 @@ function QuestionList({
|
||||
p="xs"
|
||||
radius="sm"
|
||||
style={{
|
||||
cursor: 'pointer',
|
||||
borderColor: selected.has(q.id) ? 'var(--mantine-color-blue-5)' : undefined,
|
||||
background: selected.has(q.id) ? 'var(--mantine-color-blue-0)' : undefined,
|
||||
cursor: "pointer",
|
||||
borderColor: selected.has(q.id)
|
||||
? "var(--mantine-color-blue-5)"
|
||||
: undefined,
|
||||
background: selected.has(q.id)
|
||||
? "var(--mantine-color-blue-0)"
|
||||
: undefined,
|
||||
}}
|
||||
onClick={() => onToggle(q.id)}
|
||||
>
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<Checkbox checked={selected.has(q.id)} onChange={() => onToggle(q.id)} size="xs" />
|
||||
<Checkbox
|
||||
checked={selected.has(q.id)}
|
||||
onChange={() => onToggle(q.id)}
|
||||
size="xs"
|
||||
/>
|
||||
<div style={{ flex: 1, minWidth: 0 }}>
|
||||
<Text fz="xs" lineClamp={2}>{q.title[locale]}</Text>
|
||||
<Text fz="xs" lineClamp={2}>
|
||||
{q.title[locale]}
|
||||
</Text>
|
||||
<Group gap={4} mt={2}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{q.form}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={q.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{q.form}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{q.points} pts
|
||||
</Badge>
|
||||
</Group>
|
||||
</div>
|
||||
</Group>
|
||||
@@ -91,49 +116,71 @@ function QuestionList({
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionAssigner({ available, assigned, onChange, mode = 'manual' }: QuestionAssignerProps) {
|
||||
export function QuestionAssigner({
|
||||
available,
|
||||
assigned,
|
||||
onChange,
|
||||
mode = "manual",
|
||||
actions,
|
||||
}: QuestionAssignerProps) {
|
||||
const { t } = useTranslation();
|
||||
const [searchLeft, setSearchLeft] = useState('');
|
||||
const [searchRight, setSearchRight] = useState('');
|
||||
const [searchLeft, setSearchLeft] = useState("");
|
||||
const [searchRight, setSearchRight] = useState("");
|
||||
const [selectedLeft, setSelectedLeft] = useState<Set<string>>(new Set());
|
||||
const [selectedRight, setSelectedRight] = useState<Set<string>>(new Set());
|
||||
const assignedIds = new Set(assigned.map((q) => q.id));
|
||||
|
||||
const filteredAvailable = available.filter(
|
||||
(q) => !assignedIds.has(q.id) && (q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) || q.title.am.includes(searchLeft))
|
||||
(q) =>
|
||||
!assignedIds.has(q.id) &&
|
||||
(q.title.en.toLowerCase().includes(searchLeft.toLowerCase()) ||
|
||||
q.title.am.includes(searchLeft)),
|
||||
);
|
||||
const filteredAssigned = assigned.filter(
|
||||
(q) => q.title.en.toLowerCase().includes(searchRight.toLowerCase()) || q.title.am.includes(searchRight)
|
||||
(q) =>
|
||||
q.title.en.toLowerCase().includes(searchRight.toLowerCase()) ||
|
||||
q.title.am.includes(searchRight),
|
||||
);
|
||||
|
||||
const assignSelected = () => {
|
||||
const toAssign = available.filter((q) => selectedLeft.has(q.id));
|
||||
onChange([...assigned, ...toAssign]);
|
||||
actions?.("add");
|
||||
setSelectedLeft(new Set());
|
||||
};
|
||||
|
||||
const removeSelected = () => {
|
||||
onChange(assigned.filter((q) => !selectedRight.has(q.id)));
|
||||
actions?.("remove");
|
||||
setSelectedRight(new Set());
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack gap="sm">
|
||||
{mode === 'manual' && <Text fz="sm" fw={500}>{t('exam.assigner.title')}</Text>}
|
||||
{mode === 'random' && <Text fz="sm" fw={500}>{t('exam.assigner.assignedTitle')}</Text>}
|
||||
{mode === "manual" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.title")}
|
||||
</Text>
|
||||
)}
|
||||
{mode === "random" && (
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.assigner.assignedTitle")}
|
||||
</Text>
|
||||
)}
|
||||
<Group gap="sm" align="stretch" wrap="nowrap">
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<QuestionList
|
||||
items={filteredAvailable}
|
||||
selected={selectedLeft}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedLeft);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedLeft(next);
|
||||
}}
|
||||
search={searchLeft}
|
||||
onSearchChange={setSearchLeft}
|
||||
label={t('exam.assigner.available')}
|
||||
label={t("exam.assigner.available")}
|
||||
/>
|
||||
)}
|
||||
<QuestionList
|
||||
@@ -141,32 +188,43 @@ export function QuestionAssigner({ available, assigned, onChange, mode = 'manual
|
||||
selected={selectedRight}
|
||||
onToggle={(id) => {
|
||||
const next = new Set(selectedRight);
|
||||
if (next.has(id)) next.delete(id); else next.add(id);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelectedRight(next);
|
||||
}}
|
||||
search={searchRight}
|
||||
onSearchChange={setSearchRight}
|
||||
label={t('exam.assigner.assigned')}
|
||||
label={t("exam.assigner.assigned")}
|
||||
/>
|
||||
</Group>
|
||||
{mode === 'manual' && (
|
||||
{mode === "manual" && (
|
||||
<Group gap="sm" justify="center">
|
||||
{selectedLeft.size > 0 && (
|
||||
<Button size="xs" variant="light" onClick={assignSelected}>
|
||||
{t('exam.assigner.assignSelected', { count: selectedLeft.size })}
|
||||
{t("exam.assigner.assignSelected", { count: selectedLeft.size })}
|
||||
</Button>
|
||||
)}
|
||||
{selectedRight.size > 0 && (
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
)}
|
||||
{mode === 'random' && selectedRight.size > 0 && (
|
||||
{mode === "random" && selectedRight.size > 0 && (
|
||||
<Group gap="sm" justify="center">
|
||||
<Button size="xs" variant="light" color="red" onClick={removeSelected}>
|
||||
{t('exam.assigner.removeSelected', { count: selectedRight.size })}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={removeSelected}
|
||||
>
|
||||
{t("exam.assigner.removeSelected", { count: selectedRight.size })}
|
||||
</Button>
|
||||
</Group>
|
||||
)}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState, useEffect, useRef, useMemo } from 'react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { useState, useEffect, useRef, useMemo } from "react";
|
||||
import { useNavigate, useParams } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -22,9 +22,9 @@ import {
|
||||
ThemeIcon,
|
||||
Box,
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconArrowLeft,
|
||||
IconPrinter,
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { ModalFooter, notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetExamQuery,
|
||||
@@ -56,32 +56,50 @@ import { ExamIncidentsPanel } from '../components/ExamIncidentsPanel';
|
||||
import type { ExamStatus, QuestionBrief } from '../types/exam';
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray', ACTIVE: 'blue', COMPLETED: 'teal',
|
||||
CANCELLED: 'red', POSTPONED: 'orange', PUBLISHED: 'green',
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
};
|
||||
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: 'Essay', CHOICE: 'Choice' };
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: 'Written', ORAL: 'Oral' };
|
||||
const ADMIN_LABEL: Record<string, string> = { OFFLINE: 'Offline', ONLINE: 'Online' };
|
||||
const EVAL_LABEL: Record<string, string> = { SUM: 'Sum', AVERAGE: 'Average', PERCENTAGE: 'Percentage' };
|
||||
const FORM_LABEL: Record<string, string> = { ESSAY: "Essay", CHOICE: "Choice" };
|
||||
const TYPE_LABEL: Record<string, string> = { WRITTEN: "Written", ORAL: "Oral" };
|
||||
const ADMIN_LABEL: Record<string, string> = {
|
||||
OFFLINE: "Offline",
|
||||
ONLINE: "Online",
|
||||
};
|
||||
const EVAL_LABEL: Record<string, string> = {
|
||||
SUM: "Sum",
|
||||
AVERAGE: "Average",
|
||||
PERCENTAGE: "Percentage",
|
||||
};
|
||||
|
||||
function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
return (
|
||||
<div>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>{label}</Text>
|
||||
<Text fz="sm" fw={500}>{value || '—'}</Text>
|
||||
<Text fz="xs" c="dimmed" tt="uppercase" fw={600}>
|
||||
{label}
|
||||
</Text>
|
||||
<Text fz="sm" fw={500}>
|
||||
{value || "—"}
|
||||
</Text>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamDetailPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { handleError } = useErrorHandler();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] = useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] = useDisclosure(false);
|
||||
const [recordOpened, { open: openRecord, close: closeRecord }] =
|
||||
useDisclosure(false);
|
||||
const [assignOpened, { open: openAssign, close: closeAssign }] =
|
||||
useDisclosure(false);
|
||||
const [draftQuestions, setDraftQuestions] = useState<QuestionBrief[]>([]);
|
||||
const [randomCount, setRandomCount] = useState(5);
|
||||
const [updateExam] = useUpdateExamMutation();
|
||||
@@ -109,12 +127,26 @@ export function ExamDetailPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [allQuestions, exam?.certificationId, exam?.form]);
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isLoading)
|
||||
return (
|
||||
<Center py="xl">
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
if (isError || !exam) {
|
||||
return (
|
||||
<Stack gap="md">
|
||||
<Button variant="subtle" leftSection={<IconArrowLeft size={15} />} w="fit-content" onClick={() => navigate('/exams')}>{t('exam.backToExams')}</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>{t('exam.notFound')}</Alert>
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconArrowLeft size={15} />}
|
||||
w="fit-content"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
{t("exam.backToExams")}
|
||||
</Button>
|
||||
<Alert color="red" icon={<IconInfoCircle size={17} />}>
|
||||
{t("exam.notFound")}
|
||||
</Alert>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
@@ -161,42 +193,51 @@ export function ExamDetailPage() {
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
const handlePrint = async () => {
|
||||
const total = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const total = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
if (total < Number(exam.cuttingPoint)) {
|
||||
notify.error(`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`);
|
||||
notify.error(
|
||||
`Total question marks (${total}) is less than the passing mark (${exam.cuttingPoint}). Add more questions or adjust the cutting point before printing.`,
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const printWindow = window.open('', '_blank');
|
||||
const printWindow = window.open("", "_blank");
|
||||
if (!printWindow) return;
|
||||
|
||||
let logoBase64 = '';
|
||||
let logoBase64 = "";
|
||||
try {
|
||||
const resp = await fetch('/ema-logo.png');
|
||||
const resp = await fetch("/ema-logo.png");
|
||||
const blob = await resp.blob();
|
||||
logoBase64 = await new Promise<string>((resolve) => {
|
||||
const reader = new FileReader();
|
||||
reader.onloadend = () => resolve(reader.result as string);
|
||||
reader.readAsDataURL(blob);
|
||||
});
|
||||
} catch { /* logo not available */ }
|
||||
} catch {
|
||||
/* logo not available */
|
||||
}
|
||||
|
||||
const qMap = new Map(allQuestions.map((qq) => [qq.id, qq]));
|
||||
const qHtml = (exam.questions ?? []).map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr = full?.description?.[locale] || full?.description?.en || '';
|
||||
return `
|
||||
const qHtml = (exam.questions ?? [])
|
||||
.map((q, i) => {
|
||||
const full = qMap.get(q.id);
|
||||
const titleStr = q.title[locale] || q.title.en;
|
||||
const descStr =
|
||||
full?.description?.[locale] || full?.description?.en || "";
|
||||
return `
|
||||
<div style="margin-bottom: 24px; page-break-inside: avoid;">
|
||||
<p style="font-weight: 700; margin-bottom: 4px; font-size: 13px;">Question ${i + 1} (${q.points} pts — ${FORM_LABEL[q.form] ?? q.form})</p>
|
||||
<p style="margin: 0 0 4px 0; font-size: 14px; line-height: 1.5;">${titleStr}</p>
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ''}
|
||||
${q.form === 'ESSAY' ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ''}
|
||||
${q.form === 'CHOICE' ? ['A. ______', 'B. ______', 'C. ______', 'D. ______'].map(l => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join('') : ''}
|
||||
${descStr ? `<p style="margin: 0 0 8px 0; font-size: 12px; color: #555; line-height: 1.4;">${descStr}</p>` : ""}
|
||||
${q.form === "ESSAY" ? '<div style="border-bottom: 1px dashed #ccc; height: 80px; margin-bottom: 12px;"></div>'.repeat(3) : ""}
|
||||
${q.form === "CHOICE" ? ["A. ______", "B. ______", "C. ______", "D. ______"].map((l) => `<p style="margin: 4px 0; font-size: 13px;">${l}</p>`).join("") : ""}
|
||||
</div>`;
|
||||
}).join('');
|
||||
})
|
||||
.join("");
|
||||
|
||||
printWindow.document.write(`
|
||||
<html><head><title>${exam.title[locale] || exam.title.en}</title>
|
||||
@@ -211,13 +252,13 @@ export function ExamDetailPage() {
|
||||
@media print { @page { margin: 20mm; } body { -webkit-print-color-adjust: exact; } }
|
||||
</style></head><body>
|
||||
<div class="header">
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ''}
|
||||
${logoBase64 ? `<img src="${logoBase64}" alt="Logo" class="header-logo" />` : ""}
|
||||
<h1>${exam.title[locale] || exam.title.en}</h1>
|
||||
<p>Date: ${exam.date} | Venue: ${exam.venue}</p>
|
||||
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : 'N/A'}</p>
|
||||
<p>Form: ${FORM_LABEL[exam.form]} | Type: ${TYPE_LABEL[exam.type]} | Time Allowed: ${exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : "N/A"}</p>
|
||||
<p>Pass Mark: ${exam.cuttingPoint} | Total Questions: ${(exam.questions ?? []).length}</p>
|
||||
</div>
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ''}
|
||||
${exam.direction?.[locale] ? `<div class="directions"><strong>Directions:</strong> ${exam.direction[locale]}</div>` : ""}
|
||||
${qHtml}
|
||||
<div style="margin-top: 40px; border-top: 1px solid #ccc; padding-top: 12px; font-size: 12px; color: #888; text-align: center;">
|
||||
Generated by EMA — Ethiopian Maritime Authority
|
||||
@@ -229,15 +270,25 @@ export function ExamDetailPage() {
|
||||
setTimeout(() => printWindow.print(), 500);
|
||||
};
|
||||
|
||||
const totalPoints = (exam.questions ?? []).reduce((s, q) => s + Number(q.points), 0);
|
||||
const certName = exam.certification?.name?.[locale] ?? certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ?? '—';
|
||||
const totalPoints = (exam.questions ?? []).reduce(
|
||||
(s, q) => s + Number(q.points),
|
||||
0,
|
||||
);
|
||||
const certName =
|
||||
exam.certification?.name?.[locale] ??
|
||||
certifications.find((c) => c.id === exam.certificationId)?.name?.[locale] ??
|
||||
"—";
|
||||
|
||||
return (
|
||||
<Stack gap="md" ref={printRef}>
|
||||
{/* Header */}
|
||||
<Group justify="space-between">
|
||||
<Group gap="sm">
|
||||
<ActionIcon variant="subtle" size="lg" onClick={() => navigate('/exams')}>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
onClick={() => navigate("/exams")}
|
||||
>
|
||||
<IconArrowLeft size={18} />
|
||||
</ActionIcon>
|
||||
<div>
|
||||
@@ -245,41 +296,93 @@ export function ExamDetailPage() {
|
||||
</div>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Button variant="light" leftSection={<IconPrinter size={15} />} onClick={handlePrint} size="sm">
|
||||
{t('exam.print')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPrinter size={15} />}
|
||||
onClick={handlePrint}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.print")}
|
||||
</Button>
|
||||
<Button leftSection={<IconPlus size={15} />} onClick={openRecord} size="sm">
|
||||
{t('exam.recordResult')}
|
||||
<Button
|
||||
leftSection={<IconPlus size={15} />}
|
||||
onClick={openRecord}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.recordResult")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Status badge */}
|
||||
<Badge size="lg" variant="light" color={STATUS_COLOR[exam.status]} style={{ width: 'fit-content' }}>
|
||||
<Badge
|
||||
size="lg"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[exam.status]}
|
||||
style={{ width: "fit-content" }}
|
||||
>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
</Badge>
|
||||
|
||||
{/* Exam Info */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Title order={5} mb="md">{t('exam.detail.title')}</Title>
|
||||
<Title order={5} mb="md">
|
||||
{t("exam.detail.title")}
|
||||
</Title>
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="md">
|
||||
<InfoRow label={t('exam.detail.certification')} value={certName} />
|
||||
<InfoRow label={t('exam.detail.type')} value={t(`exam.type.${exam.type}`)} />
|
||||
<InfoRow label={t('exam.detail.form')} value={t(`exam.formType.${exam.form}`)} />
|
||||
<InfoRow label={t('exam.detail.venue')} value={exam.venue} />
|
||||
<InfoRow label={t('exam.detail.date')} value={exam.date} />
|
||||
<InfoRow label={t('exam.detail.administration')} value={t(`exam.admin.${exam.administrationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.evaluation')} value={t(`exam.eval.${exam.evaluationMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.selection')} value={t(`exam.selection.${exam.selectionMethod}`)} />
|
||||
<InfoRow label={t('exam.detail.timeAllowed')} value={exam.givenTime ? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m` : '—'} />
|
||||
<InfoRow label={t('exam.detail.passMark')} value={String(exam.cuttingPoint)} />
|
||||
<InfoRow label={t('exam.detail.totalPoints')} value={String(totalPoints)} />
|
||||
<InfoRow label={t('exam.detail.questions')} value={String((exam.questions ?? []).length)} />
|
||||
<InfoRow label={t("exam.detail.certification")} value={certName} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.type")}
|
||||
value={t(`exam.type.${exam.type}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.form")}
|
||||
value={t(`exam.formType.${exam.form}`)}
|
||||
/>
|
||||
<InfoRow label={t("exam.detail.venue")} value={exam.venue} />
|
||||
<InfoRow label={t("exam.detail.date")} value={exam.date} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.administration")}
|
||||
value={t(`exam.admin.${exam.administrationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.evaluation")}
|
||||
value={t(`exam.eval.${exam.evaluationMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.selection")}
|
||||
value={t(`exam.selection.${exam.selectionMethod}`)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.timeAllowed")}
|
||||
value={
|
||||
exam.givenTime
|
||||
? `${exam.givenTime.days}d ${exam.givenTime.hours}h ${exam.givenTime.minutes}m`
|
||||
: "—"
|
||||
}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.passMark")}
|
||||
value={String(exam.cuttingPoint)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.totalPoints")}
|
||||
value={String(totalPoints)}
|
||||
/>
|
||||
<InfoRow
|
||||
label={t("exam.detail.questions")}
|
||||
value={String((exam.questions ?? []).length)}
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{(exam.direction?.en || exam.direction?.am) && (
|
||||
<>
|
||||
<Divider my="md" />
|
||||
<InfoRow label={t('exam.detail.directions')} value={[exam.direction?.en, exam.direction?.am].filter(Boolean).join(' / ')} />
|
||||
<InfoRow
|
||||
label={t("exam.detail.directions")}
|
||||
value={[exam.direction?.en, exam.direction?.am]
|
||||
.filter(Boolean)
|
||||
.join(" / ")}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</Paper>
|
||||
@@ -287,24 +390,41 @@ export function ExamDetailPage() {
|
||||
{/* Questions */}
|
||||
<Paper withBorder radius="lg" p="lg">
|
||||
<Group justify="space-between" mb="md">
|
||||
<Title order={5}>{t('exam.detail.questionsSection', { pts: totalPoints })}</Title>
|
||||
<Button variant="light" size="xs" leftSection={<IconPlus size={14} />} onClick={openAssignModal}>
|
||||
{t('exam.manageQuestions')}
|
||||
<Title order={5}>
|
||||
{t("exam.detail.questionsSection", { pts: totalPoints })}
|
||||
</Title>
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
leftSection={<IconPlus size={14} />}
|
||||
onClick={openAssignModal}
|
||||
>
|
||||
{t("exam.manageQuestions")}
|
||||
</Button>
|
||||
</Group>
|
||||
{(exam.questions ?? []).length === 0 ? (
|
||||
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
|
||||
{t('exam.noQuestionsAssigned')}
|
||||
{t("exam.noQuestionsAssigned")}
|
||||
</Alert>
|
||||
) : (
|
||||
<Stack gap="md">
|
||||
{(exam.questions ?? []).map((q, i) => (
|
||||
<Paper key={q.id} withBorder p="md" radius="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fz="sm" fw={700}>{t('exam.detail.questionLabel')} {i + 1}</Text>
|
||||
<Text fz="sm" fw={700}>
|
||||
{t("exam.detail.questionLabel")} {i + 1}
|
||||
</Text>
|
||||
<Group gap={4}>
|
||||
<Badge size="xs" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`exam.formType.${q.form}`)}</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">{q.points} pts</Badge>
|
||||
<Badge
|
||||
size="xs"
|
||||
variant="light"
|
||||
color={q.form === "ESSAY" ? "blue" : "violet"}
|
||||
>
|
||||
{t(`exam.formType.${q.form}`)}
|
||||
</Badge>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{q.points} pts
|
||||
</Badge>
|
||||
</Group>
|
||||
</Group>
|
||||
<Text fz="sm">{q.title[locale]}</Text>
|
||||
@@ -321,9 +441,15 @@ export function ExamDetailPage() {
|
||||
<RecordResultModal exam={exam} opened={recordOpened} onClose={closeRecord} />
|
||||
|
||||
{/* Question assignment modal */}
|
||||
<Modal opened={assignOpened} onClose={closeAssign} title={`${t('exam.manageQuestions')} — ${exam.title[locale]}`} size="xl" radius="lg">
|
||||
<Modal
|
||||
opened={assignOpened}
|
||||
onClose={closeAssign}
|
||||
title={`${t("exam.manageQuestions")} — ${exam.title[locale]}`}
|
||||
size="xl"
|
||||
radius="lg"
|
||||
>
|
||||
<Stack gap="md">
|
||||
{exam.selectionMethod === 'MANUAL' ? (
|
||||
{exam.selectionMethod === "MANUAL" ? (
|
||||
<>
|
||||
<QuestionAssigner
|
||||
available={eligibleQuestions}
|
||||
@@ -331,17 +457,21 @@ export function ExamDetailPage() {
|
||||
onChange={setDraftQuestions}
|
||||
mode="manual"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Text fz="sm" c="dimmed">{t('exam.randomHintServer')}</Text>
|
||||
<Group gap="sm">
|
||||
<NumberInput
|
||||
placeholder={t('exam.assigner.selectCount')}
|
||||
placeholder={t("exam.assigner.selectCount")}
|
||||
value={randomCount}
|
||||
onChange={(v) => setRandomCount(Number(v))}
|
||||
min={1}
|
||||
@@ -358,10 +488,14 @@ export function ExamDetailPage() {
|
||||
onChange={setDraftQuestions}
|
||||
mode="random"
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeAssign} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>{t('exam.saveAssignments')}</Button>
|
||||
</Group>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeAssign} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button onClick={handleAssign} size="sm" loading={isAssigning}>
|
||||
{t("exam.saveAssignments")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
</Stack>
|
||||
|
||||
@@ -1,29 +1,47 @@
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { ActionIcon, Group } from "@mantine/core";
|
||||
import { IconEdit, IconTrash, IconDetails } from "@tabler/icons-react";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
export function examColumnActions(
|
||||
export function examActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onEdit: (exam: Exam) => void;
|
||||
onDelete: (exam: Exam) => void;
|
||||
onDetails: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableAction<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('exam.update'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('exam.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
): AdvancedColumn<Exam> {
|
||||
return {
|
||||
header: t("exam.columns.actions"),
|
||||
align: "right",
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="blue"
|
||||
size="sm"
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="red"
|
||||
size="sm"
|
||||
onClick={() => handlers.onDetails(row.original)}
|
||||
>
|
||||
<IconDetails size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,89 +1,79 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { Badge, Text } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import type { Exam } from "../../types/exam";
|
||||
|
||||
const STATUS_COLOR: Record<string, string> = {
|
||||
PENDING: 'gray',
|
||||
ACTIVE: 'blue',
|
||||
COMPLETED: 'teal',
|
||||
CANCELLED: 'red',
|
||||
POSTPONED: 'orange',
|
||||
PUBLISHED: 'green',
|
||||
PENDING: "gray",
|
||||
ACTIVE: "blue",
|
||||
COMPLETED: "teal",
|
||||
CANCELLED: "red",
|
||||
POSTPONED: "orange",
|
||||
PUBLISHED: "green",
|
||||
};
|
||||
|
||||
export function examColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
onTitleClick: (exam: Exam) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Exam>[] {
|
||||
locale: "en" | "am",
|
||||
getCertName: (id: string) => string,
|
||||
onTitleClick: (exam: Exam) => void,
|
||||
): AdvancedColumn<Exam>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('exam.columns.title'),
|
||||
render: (exam) => (
|
||||
header: t("exam.columns.title"),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
fz="sm"
|
||||
fw={500}
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlers.onTitleClick(exam)}
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => onTitleClick(row.original)}
|
||||
>
|
||||
{exam.title[handlers.locale]}
|
||||
{row.original.title[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('exam.columns.certification'),
|
||||
render: (exam) => <Text fz="sm">{handlers.getCertName(exam.certificationId)}</Text>,
|
||||
header: t("exam.columns.certification"),
|
||||
cell: ({ row }) => <Text fz="sm">{getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: t('exam.columns.date'),
|
||||
render: (exam) => <Text fz="sm">{exam.date}</Text>,
|
||||
header: t("exam.columns.date"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.date}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('exam.columns.type'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.type === 'WRITTEN' ? 'blue' : 'orange'}>
|
||||
{t(`exam.type.${exam.type}`)}
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('exam.columns.form'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={exam.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`exam.formType.${exam.form}`)}
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
header: t('exam.columns.venue'),
|
||||
render: (exam) => <Text fz="sm">{exam.venue}</Text>,
|
||||
header: t("exam.columns.venue"),
|
||||
cell: ({ row }) => <Text fz="sm">{row.original.venue}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'questions',
|
||||
header: t('exam.columns.questions'),
|
||||
render: (exam) => (
|
||||
header: t("exam.columns.questions"),
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{exam.questions?.length ?? 0}
|
||||
{row.original.questions?.length ?? 0}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('exam.columns.status'),
|
||||
render: (exam) => (
|
||||
<Badge size="sm" variant="light" color={STATUS_COLOR[exam.status]}>
|
||||
{t(`exam.status.${exam.status}`)}
|
||||
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>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
import {
|
||||
Stack,
|
||||
Title,
|
||||
@@ -9,30 +9,31 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
Tabs,
|
||||
SimpleGrid,
|
||||
Divider,
|
||||
} from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
} from "@mantine/core";
|
||||
import { useDisclosure } from "@mantine/hooks";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
IconPlus,
|
||||
IconInfoCircle,
|
||||
IconClipboardList,
|
||||
} from "@tabler/icons-react";
|
||||
import { notify, useErrorHandler, AdvancedTable, useServerTable, ModalFooter, AmharicDatePicker } from "@ema-platform/ui";
|
||||
import { useGetCertificationsQuery } from "../../../certification/api/certification-api";
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
useCreateExamMutation,
|
||||
useUpdateExamMutation,
|
||||
useDeleteExamMutation,
|
||||
} from '../../api/exam-api';
|
||||
import type { Exam } from '../../types/exam';
|
||||
import { examColumns } from './columns';
|
||||
import { examColumnActions } from './actions';
|
||||
} from "../../api/exam-api";
|
||||
import type { Exam } from "../../types/exam";
|
||||
import { examColumns } from "./columns";
|
||||
import { examActionsColumn } from "./actions";
|
||||
|
||||
function ExamForm({
|
||||
editing,
|
||||
@@ -48,100 +49,297 @@ function ExamForm({
|
||||
onCancel: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [certificationId, setCertificationId] = useState<string | null>(editing?.certificationId ?? null);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? '');
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? '');
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? '');
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? '');
|
||||
const [date, setDate] = useState(editing?.date ?? '');
|
||||
const [certificationId, setCertificationId] = useState<string | null>(
|
||||
editing?.certificationId ?? null,
|
||||
);
|
||||
const [titleEn, setTitleEn] = useState(editing?.title?.en ?? "");
|
||||
const [titleAm, setTitleAm] = useState(editing?.title?.am ?? "");
|
||||
const [directionEn, setDirectionEn] = useState(editing?.direction?.en ?? "");
|
||||
const [directionAm, setDirectionAm] = useState(editing?.direction?.am ?? "");
|
||||
const [date, setDate] = useState(editing?.date ?? "");
|
||||
const [days, setDays] = useState(editing?.givenTime?.days ?? 0);
|
||||
const [hours, setHours] = useState(editing?.givenTime?.hours ?? 0);
|
||||
const [minutes, setMinutes] = useState(editing?.givenTime?.minutes ?? 0);
|
||||
const [type, setType] = useState<string | null>(editing?.type ?? null);
|
||||
const [form, setForm] = useState<string | null>(editing?.form ?? null);
|
||||
const [venue, setVenue] = useState(editing?.venue ?? '');
|
||||
const [adminMethod, setAdminMethod] = useState<string | null>(editing?.administrationMethod ?? null);
|
||||
const [evalMethod, setEvalMethod] = useState<string | null>(editing?.evaluationMethod ?? null);
|
||||
const [selMethod, setSelMethod] = useState<string | null>(editing?.selectionMethod ?? null);
|
||||
const [cuttingPoint, setCuttingPoint] = useState<number>(editing?.cuttingPoint ?? 0);
|
||||
const [venue, setVenue] = useState(editing?.venue ?? "");
|
||||
const [adminMethod, setAdminMethod] = useState<string | null>(
|
||||
editing?.administrationMethod ?? null,
|
||||
);
|
||||
const [evalMethod, setEvalMethod] = useState<string | null>(
|
||||
editing?.evaluationMethod ?? null,
|
||||
);
|
||||
const [selMethod, setSelMethod] = useState<string | null>(
|
||||
editing?.selectionMethod ?? null,
|
||||
);
|
||||
const [cuttingPoint, setCuttingPoint] = useState<number>(
|
||||
editing?.cuttingPoint ?? 0,
|
||||
);
|
||||
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!certificationId || !titleEn || !titleAm || !date || !type || !form || !venue || !adminMethod || !evalMethod) {
|
||||
notify.error('Please fill all required fields');
|
||||
if (
|
||||
!certificationId ||
|
||||
!titleEn ||
|
||||
!titleAm ||
|
||||
!date ||
|
||||
!type ||
|
||||
!form ||
|
||||
!venue ||
|
||||
!adminMethod ||
|
||||
!evalMethod
|
||||
) {
|
||||
notify.error("Please fill all required fields");
|
||||
return;
|
||||
}
|
||||
onSubmit({
|
||||
certificationId, titleEn, titleAm, directionEn, directionAm,
|
||||
date, days, hours, minutes, type, form, venue, adminMethod, evalMethod, selMethod, cuttingPoint, status,
|
||||
}, !!editing);
|
||||
onSubmit(
|
||||
{
|
||||
certificationId,
|
||||
titleEn,
|
||||
titleAm,
|
||||
directionEn,
|
||||
directionAm,
|
||||
date,
|
||||
days,
|
||||
hours,
|
||||
minutes,
|
||||
type,
|
||||
form,
|
||||
venue,
|
||||
adminMethod,
|
||||
evalMethod,
|
||||
selMethod,
|
||||
cuttingPoint,
|
||||
status,
|
||||
},
|
||||
!!editing,
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t("exam.update") : t("exam.add")} size="xl">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>{t('exam.form.basicInfo')}</Tabs.Tab>
|
||||
<Tabs.Tab value="settings" leftSection={<IconClipboardList size={15} />}>{t('exam.form.settings')}</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
<Tabs defaultValue="basic" variant="outline" radius="md">
|
||||
<Tabs.List mb="md">
|
||||
<Tabs.Tab value="basic" leftSection={<IconInfoCircle size={15} />}>
|
||||
{t("exam.form.basicInfo")}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab
|
||||
value="settings"
|
||||
leftSection={<IconClipboardList size={15} />}
|
||||
>
|
||||
{t("exam.form.settings")}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select label={t('exam.form.certification')} placeholder={t('exam.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
<TextInput label={t('exam.form.titleEn')} placeholder={t('exam.form.titleEnPlaceholder')} value={titleEn} onChange={(e) => setTitleEn(e.currentTarget.value)} size="sm" required />
|
||||
<TextInput label={t('exam.form.titleAm')} placeholder={t('exam.form.titleAmPlaceholder')} value={titleAm} onChange={(e) => setTitleAm(e.currentTarget.value)} size="sm" required />
|
||||
<Textarea label={t('exam.form.directionEn')} placeholder={t('exam.form.directionEnPlaceholder')} value={directionEn} onChange={(e) => setDirectionEn(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<Textarea label={t('exam.form.directionAm')} placeholder={t('exam.form.directionAmPlaceholder')} value={directionAm} onChange={(e) => setDirectionAm(e.currentTarget.value)} size="sm" autosize minRows={2} />
|
||||
<TextInput label={t('exam.form.examDate')} type="date" value={date} onChange={(e) => setDate(e.currentTarget.value)} size="sm" leftSection={<IconCalendar size={14} />} required />
|
||||
<TextInput label={t('exam.form.venue')} placeholder={t('exam.form.venuePlaceholder')} value={venue} onChange={(e) => setVenue(e.currentTarget.value)} size="sm" required />
|
||||
<Tabs.Panel value="basic">
|
||||
<Stack gap="sm">
|
||||
<Select
|
||||
label={t("exam.form.certification")}
|
||||
placeholder={t("exam.form.selectCertification")}
|
||||
data={certOptions}
|
||||
value={certificationId}
|
||||
onChange={setCertificationId}
|
||||
size="sm"
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleEn")}
|
||||
placeholder={t("exam.form.titleEnPlaceholder")}
|
||||
value={titleEn}
|
||||
onChange={(e) => setTitleEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.titleAm")}
|
||||
placeholder={t("exam.form.titleAmPlaceholder")}
|
||||
value={titleAm}
|
||||
onChange={(e) => setTitleAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionEn")}
|
||||
placeholder={t("exam.form.directionEnPlaceholder")}
|
||||
value={directionEn}
|
||||
onChange={(e) => setDirectionEn(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<Textarea
|
||||
label={t("exam.form.directionAm")}
|
||||
placeholder={t("exam.form.directionAmPlaceholder")}
|
||||
value={directionAm}
|
||||
onChange={(e) => setDirectionAm(e.currentTarget.value)}
|
||||
size="sm"
|
||||
autosize
|
||||
minRows={2}
|
||||
/>
|
||||
<AmharicDatePicker
|
||||
label={t("exam.form.examDate")}
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
dateFormat="date"
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<TextInput
|
||||
label={t("exam.form.venue")}
|
||||
placeholder={t("exam.form.venuePlaceholder")}
|
||||
value={venue}
|
||||
onChange={(e) => setVenue(e.currentTarget.value)}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
|
||||
<Text fz="sm" fw={500}>{t('exam.form.timeAllowed')}</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput label={t('exam.form.days')} value={days} onChange={(v) => setDays(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('exam.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
<Text fz="sm" fw={500}>
|
||||
{t("exam.form.timeAllowed")}
|
||||
</Text>
|
||||
<Group gap="sm" grow>
|
||||
<NumberInput
|
||||
label={t("exam.form.days")}
|
||||
value={days}
|
||||
onChange={(v) => setDays(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.hours")}
|
||||
value={hours}
|
||||
onChange={(v) => setHours(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.minutes")}
|
||||
value={minutes}
|
||||
onChange={(v) => setMinutes(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
/>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select label={t('exam.columns.type')} placeholder="Written or Oral" data={[{ value: 'WRITTEN', label: t('exam.form.written') }, { value: 'ORAL', label: t('exam.form.oral') }]} value={type} onChange={setType} size="sm" required />
|
||||
<Select label={t('exam.columns.form')} placeholder="Essay or Choice" data={[{ value: 'ESSAY', label: t('exam.form.essay') }, { value: 'CHOICE', label: t('exam.form.choice') }]} value={form} onChange={setForm} size="sm" required />
|
||||
<Select label={t('exam.detail.administration')} placeholder="Offline or Online" data={[{ value: 'OFFLINE', label: t('exam.form.offline') }, { value: 'ONLINE', label: t('exam.form.online') }]} value={adminMethod} onChange={setAdminMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.evaluation')} placeholder="How to compute score" data={[{ value: 'SUM', label: t('exam.form.sum') }, { value: 'AVERAGE', label: t('exam.form.average') }, { value: 'PERCENTAGE', label: t('exam.form.percentage') }]} value={evalMethod} onChange={setEvalMethod} size="sm" required />
|
||||
<Select label={t('exam.detail.selection')} placeholder="Manual or Random" data={[{ value: 'MANUAL', label: t('exam.form.manual') }, { value: 'RANDOM', label: t('exam.form.random') }]} value={selMethod} onChange={setSelMethod} size="sm" />
|
||||
<NumberInput label={t('exam.form.cuttingPoint')} placeholder={t('exam.form.cuttingPointPlaceholder')} value={cuttingPoint} onChange={(v) => setCuttingPoint(Number(v))} min={0} size="sm" required />
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select label={t('exam.form.status')} placeholder={t('exam.form.statusPlaceholder')} data={[
|
||||
{ value: 'PENDING', label: t('exam.form.pending') }, { value: 'ACTIVE', label: t('exam.form.active') },
|
||||
{ value: 'COMPLETED', label: t('exam.form.completed') }, { value: 'CANCELLED', label: t('exam.form.cancelled') },
|
||||
{ value: 'POSTPONED', label: t('exam.form.postponed') }, { value: 'PUBLISHED', label: t('exam.form.published') },
|
||||
]} value={status} onChange={setStatus} size="sm" />
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
<Tabs.Panel value="settings">
|
||||
<Stack gap="sm">
|
||||
<SimpleGrid cols={2} spacing="sm">
|
||||
<Select
|
||||
label={t("exam.columns.type")}
|
||||
placeholder="Written or Oral"
|
||||
data={[
|
||||
{ value: "WRITTEN", label: t("exam.form.written") },
|
||||
{ value: "ORAL", label: t("exam.form.oral") },
|
||||
]}
|
||||
value={type}
|
||||
onChange={setType}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.columns.form")}
|
||||
placeholder="Essay or Choice"
|
||||
data={[
|
||||
{ value: "ESSAY", label: t("exam.form.essay") },
|
||||
{ value: "CHOICE", label: t("exam.form.choice") },
|
||||
]}
|
||||
value={form}
|
||||
onChange={setForm}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.administration")}
|
||||
placeholder="Offline or Online"
|
||||
data={[
|
||||
{ value: "OFFLINE", label: t("exam.form.offline") },
|
||||
{ value: "ONLINE", label: t("exam.form.online") },
|
||||
]}
|
||||
value={adminMethod}
|
||||
onChange={setAdminMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.evaluation")}
|
||||
placeholder="How to compute score"
|
||||
data={[
|
||||
{ value: "SUM", label: t("exam.form.sum") },
|
||||
{ value: "AVERAGE", label: t("exam.form.average") },
|
||||
{ value: "PERCENTAGE", label: t("exam.form.percentage") },
|
||||
]}
|
||||
value={evalMethod}
|
||||
onChange={setEvalMethod}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
<Select
|
||||
label={t("exam.detail.selection")}
|
||||
placeholder="Manual or Random"
|
||||
data={[
|
||||
{ value: "MANUAL", label: t("exam.form.manual") },
|
||||
{ value: "RANDOM", label: t("exam.form.random") },
|
||||
]}
|
||||
value={selMethod}
|
||||
onChange={setSelMethod}
|
||||
size="sm"
|
||||
/>
|
||||
<NumberInput
|
||||
label={t("exam.form.cuttingPoint")}
|
||||
placeholder={t("exam.form.cuttingPointPlaceholder")}
|
||||
value={cuttingPoint}
|
||||
onChange={(v) => setCuttingPoint(Number(v))}
|
||||
min={0}
|
||||
size="sm"
|
||||
required
|
||||
/>
|
||||
</SimpleGrid>
|
||||
{editing && (
|
||||
<Select
|
||||
label={t("exam.form.status")}
|
||||
placeholder={t("exam.form.statusPlaceholder")}
|
||||
data={[
|
||||
{ value: "PENDING", label: t("exam.form.pending") },
|
||||
{ value: "ACTIVE", label: t("exam.form.active") },
|
||||
{ value: "COMPLETED", label: t("exam.form.completed") },
|
||||
{ value: "CANCELLED", label: t("exam.form.cancelled") },
|
||||
{ value: "POSTPONED", label: t("exam.form.postponed") },
|
||||
{ value: "PUBLISHED", label: t("exam.form.published") },
|
||||
]}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
size="sm"
|
||||
/>
|
||||
)}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<Group justify="flex-end" mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('exam.update') : t('exam.create')}</Button>
|
||||
</Group>
|
||||
<ModalFooter mt="md">
|
||||
<Button variant="default" onClick={onCancel} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>
|
||||
{editing ? t("exam.update") : t("exam.create")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function ExamPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as "en" | "am";
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetExamsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetExamsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createExam, { isLoading: isCreating }] = useCreateExamMutation();
|
||||
const [updateExam, { isLoading: isUpdating }] = useUpdateExamMutation();
|
||||
const [deleteExam] = useDeleteExamMutation();
|
||||
@@ -152,26 +350,40 @@ export function ExamPage() {
|
||||
const [editing, setEditing] = useState<Exam | null>(null);
|
||||
const [showForm, setShowForm] = useState(false);
|
||||
const [deleteTarget, setDeleteTarget] = useState<Exam | null>(null);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] = useDisclosure(false);
|
||||
const [deleteOpened, { open: openDelete, close: closeDelete }] =
|
||||
useDisclosure(false);
|
||||
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
const certOptions = certifications
|
||||
.filter((c) => c.isActive)
|
||||
.map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
const getCertName = (id: string) =>
|
||||
certifications.find((c) => c.id === id)?.name?.[locale] ?? "-";
|
||||
|
||||
const resetForm = () => { setEditing(null); setShowForm(false); };
|
||||
const resetForm = () => {
|
||||
setEditing(null);
|
||||
setShowForm(false);
|
||||
};
|
||||
|
||||
const handleSubmit = async (values: any, isEdit: boolean) => {
|
||||
const payload: any = {
|
||||
certificationId: values.certificationId,
|
||||
title: { en: values.titleEn, am: values.titleAm },
|
||||
direction: values.directionEn || values.directionAm ? { en: values.directionEn, am: values.directionAm } : undefined,
|
||||
direction:
|
||||
values.directionEn || values.directionAm
|
||||
? { en: values.directionEn, am: values.directionAm }
|
||||
: undefined,
|
||||
date: values.date,
|
||||
givenTime: { days: values.days, hours: values.hours, minutes: values.minutes },
|
||||
givenTime: {
|
||||
days: values.days,
|
||||
hours: values.hours,
|
||||
minutes: values.minutes,
|
||||
},
|
||||
type: values.type,
|
||||
form: values.form,
|
||||
venue: values.venue,
|
||||
administrationMethod: values.adminMethod,
|
||||
evaluationMethod: values.evalMethod,
|
||||
selectionMethod: values.selMethod || 'MANUAL',
|
||||
selectionMethod: values.selMethod || "MANUAL",
|
||||
cuttingPoint: values.cuttingPoint,
|
||||
};
|
||||
if (isEdit) payload.status = values.status;
|
||||
@@ -179,14 +391,14 @@ export function ExamPage() {
|
||||
try {
|
||||
if (isEdit && editing) {
|
||||
await updateExam({ id: editing.id, ...payload }).unwrap();
|
||||
notify.success(t('exam.updated'));
|
||||
notify.success(t("exam.updated"));
|
||||
} else {
|
||||
await createExam(payload).unwrap();
|
||||
notify.success(t('exam.created'));
|
||||
notify.success(t("exam.created"));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -194,27 +406,57 @@ export function ExamPage() {
|
||||
if (!deleteTarget) return;
|
||||
try {
|
||||
await deleteExam(deleteTarget.id).unwrap();
|
||||
notify.success(t('exam.deleted'));
|
||||
notify.success(t("exam.deleted"));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('exam.loadError')} />;
|
||||
if (isError)
|
||||
return (
|
||||
<Alert
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
color="red"
|
||||
title={t("exam.loadError")}
|
||||
/>
|
||||
);
|
||||
|
||||
const columns = [
|
||||
...examColumns(t, locale, getCertName, (exam) => navigate(`/exams/${exam.id}`)),
|
||||
examActionsColumn(t, {
|
||||
onEdit: (exam) => {
|
||||
setEditing(exam);
|
||||
setShowForm(true);
|
||||
},
|
||||
onDelete: (exam) => {
|
||||
setDeleteTarget(exam);
|
||||
openDelete();
|
||||
},
|
||||
onDetails: (exam) => navigate(`/exams/${exam.id}`),
|
||||
}),
|
||||
];
|
||||
|
||||
const page = paginate(exams);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
<div>
|
||||
<Title order={2}>{t('exam.title')}</Title>
|
||||
<Text fz="sm" c="dimmed">{t('exam.subtitle')}</Text>
|
||||
<Title order={2}>{t("exam.title")}</Title>
|
||||
<Text fz="sm" c="dimmed">
|
||||
{t("exam.subtitle")}
|
||||
</Text>
|
||||
</div>
|
||||
{!showForm && (
|
||||
<Button variant="light" leftSection={<IconPlus size={16} />} onClick={() => setShowForm(true)} size="sm">
|
||||
{t('exam.add')}
|
||||
<Button
|
||||
variant="light"
|
||||
leftSection={<IconPlus size={16} />}
|
||||
onClick={() => setShowForm(true)}
|
||||
size="sm"
|
||||
>
|
||||
{t("exam.add")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -229,31 +471,42 @@ export function ExamPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={examColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
onTitleClick: (exam) => navigate(`/exams/${exam.id}`),
|
||||
})}
|
||||
data={exams}
|
||||
rowKey={(exam) => exam.id}
|
||||
actions={examColumnActions(t, {
|
||||
onEdit: (exam) => { setEditing(exam); setShowForm(true); },
|
||||
onDelete: (exam) => { setDeleteTarget(exam); openDelete(); },
|
||||
})}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('exam.noItems')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t("exam.title")}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t("exam.noItems")}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
{/* Delete confirmation */}
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('exam.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('exam.deleteConfirmText', { name: deleteTarget?.title?.[locale] ?? '' })}</Text>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('exam.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('exam.delete')}</Button>
|
||||
</Group>
|
||||
<Modal
|
||||
opened={deleteOpened}
|
||||
onClose={closeDelete}
|
||||
title={t("exam.confirmDelete")}
|
||||
size="sm"
|
||||
>
|
||||
<Text mb="md">
|
||||
{t("exam.deleteConfirmText", {
|
||||
name: deleteTarget?.title?.[locale] ?? "",
|
||||
})}
|
||||
</Text>
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">
|
||||
{t("exam.cancel")}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">
|
||||
{t("exam.delete")}
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,13 +1,19 @@
|
||||
import type { LocalePair } from '../../certification/types/certification';
|
||||
import type { EstimatedTime } from '../../question/types/question';
|
||||
import type { QuestionForm } from '../../question/types/question';
|
||||
import type { LocalePair } from "../../certification/types/certification";
|
||||
import type { EstimatedTime } from "../../question/types/question";
|
||||
import type { QuestionForm } from "../../question/types/question";
|
||||
export type { QuestionForm };
|
||||
|
||||
export type ExamType = 'WRITTEN' | 'ORAL';
|
||||
export type ExamAdministrationMethod = 'OFFLINE' | 'ONLINE';
|
||||
export type ExamEvaluationMethod = 'SUM' | 'AVERAGE' | 'PERCENTAGE';
|
||||
export type ExamSelectionMethod = 'MANUAL' | 'RANDOM';
|
||||
export type ExamStatus = 'PENDING' | 'ACTIVE' | 'COMPLETED' | 'CANCELLED' | 'POSTPONED' | 'PUBLISHED';
|
||||
export type ExamType = "WRITTEN" | "ORAL";
|
||||
export type ExamAdministrationMethod = "OFFLINE" | "ONLINE";
|
||||
export type ExamEvaluationMethod = "SUM" | "AVERAGE" | "PERCENTAGE";
|
||||
export type ExamSelectionMethod = "MANUAL" | "RANDOM";
|
||||
export type ExamStatus =
|
||||
| "PENDING"
|
||||
| "ACTIVE"
|
||||
| "COMPLETED"
|
||||
| "CANCELLED"
|
||||
| "POSTPONED"
|
||||
| "PUBLISHED";
|
||||
|
||||
export interface QuestionBrief {
|
||||
id: string;
|
||||
|
||||
@@ -1,17 +1,28 @@
|
||||
import { ActionIcon, Group, Tooltip } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
export function itemColumnActions(handlers: {
|
||||
export function itemActionsColumn(handlers: {
|
||||
onDelete: (item: Item) => void;
|
||||
}): AdvancedTableAction<Item>[] {
|
||||
return [
|
||||
{
|
||||
key: 'delete',
|
||||
label: 'Delete',
|
||||
color: 'red',
|
||||
icon: <IconTrash size={16} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
}): AdvancedColumn<Item> {
|
||||
return {
|
||||
header: '',
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label="Delete">
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label="Delete"
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Item } from '../../api/item-api';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
@@ -8,16 +8,18 @@ const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
ARCHIVED: 'orange',
|
||||
};
|
||||
|
||||
export const itemColumns: AdvancedTableColumn<Item>[] = [
|
||||
{ key: 'name', header: 'Name' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (item) => <Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
header: 'Created',
|
||||
render: (item) => new Date(item.createdAt).toLocaleDateString(),
|
||||
},
|
||||
];
|
||||
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
|
||||
return [
|
||||
{ header: 'Name', accessorKey: 'name' },
|
||||
{
|
||||
header: 'Status',
|
||||
cell: ({ row }) => (
|
||||
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
header: 'Created',
|
||||
cell: ({ row }) => showDate(row.original.createdAt),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,30 +1,39 @@
|
||||
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../../api/item-api';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { itemColumns } from './columns';
|
||||
import { itemColumnActions } from './actions';
|
||||
import { itemActionsColumn } from './actions';
|
||||
|
||||
export function ItemTable() {
|
||||
const { data, isLoading, refetch } = useGetItemsQuery({});
|
||||
const [deleteItem] = useDeleteItemMutation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const showDate = useDateDisplayer();
|
||||
const table = useServerTable();
|
||||
|
||||
const handleDelete = async (item: Item) => {
|
||||
try {
|
||||
await deleteItem(item.id).unwrap();
|
||||
notify.success('Item deleted');
|
||||
} catch {
|
||||
notify.error('Failed to delete item');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
const paged = table.paginate(data?.data ?? []);
|
||||
|
||||
return (
|
||||
<AdvancedTable
|
||||
columns={itemColumns}
|
||||
data={data?.data ?? []}
|
||||
rowKey={(item) => item.id}
|
||||
actions={itemColumnActions({ onDelete: handleDelete })}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No items found"
|
||||
<AdvancedTable<Item>
|
||||
tableName="Items"
|
||||
columns={[...itemColumns(showDate), itemActionsColumn({ onDelete: handleDelete })]}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
emptyText="No items found"
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { localized } from '@ema-platform/api';
|
||||
import type { IssuedLicense } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
const LICENSE_STATUS_COLORS: Record<string, string> = {
|
||||
ACTIVE: 'green',
|
||||
@@ -27,73 +26,70 @@ export function actionsFor(license: IssuedLicense): LifecycleAction[] {
|
||||
}
|
||||
}
|
||||
|
||||
export function licenseRegisterColumns(handlers: {
|
||||
onStatus: (license: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
export function licenseRegisterColumns(
|
||||
localized: (value: Bilingual | undefined) => string,
|
||||
showDate: (date: string | null | undefined) => string,
|
||||
handlers: { onStatus: (license: IssuedLicense) => void },
|
||||
): AdvancedColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'certificateNumber',
|
||||
header: 'Certificate №',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{license.certificateNumber}
|
||||
{row.original.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
render: (license) => (
|
||||
<Text size="sm">{localized(license.licenseType?.name)}</Text>
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{localized(row.original.licenseType?.name)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'holder',
|
||||
header: 'Holder',
|
||||
render: (license) => <Text size="sm">{license.companyName ?? '—'}</Text>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'issued',
|
||||
header: 'Issued',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.issueDate?.slice(0, 10)}
|
||||
{showDate(row.original.issueDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'expires',
|
||||
header: 'Expires',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{license.expiryDate?.slice(0, 10)}
|
||||
{showDate(row.original.expiryDate)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={LICENSE_STATUS_COLORS[license.status] ?? 'gray'}
|
||||
color={LICENSE_STATUS_COLORS[row.original.status] ?? 'gray'}
|
||||
>
|
||||
{license.status}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lifecycle',
|
||||
header: '',
|
||||
render: (license) =>
|
||||
actionsFor(license).length > 0 ? (
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) =>
|
||||
actionsFor(row.original).length > 0 ? (
|
||||
<Tooltip label="Suspend / revoke / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={() => handlers.onStatus(license)}
|
||||
onClick={() => handlers.onStatus(row.original)}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
|
||||
@@ -13,9 +13,11 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconSearch } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetLicensesQuery,
|
||||
useReinstateLicenseMutation,
|
||||
useRevokeLicenseMutation,
|
||||
@@ -142,6 +144,10 @@ export function LicenseRegisterPage() {
|
||||
const [target, setTarget] = useState<IssuedLicense | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const table = useServerTable();
|
||||
const paged = table.paginate(items);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -162,13 +168,17 @@ export function LicenseRegisterPage() {
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={licenseRegisterColumns({ onStatus: setTarget })}
|
||||
data={items}
|
||||
rowKey={(license) => license.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={
|
||||
<AdvancedTable<IssuedLicense>
|
||||
tableName="Licence register"
|
||||
columns={licenseRegisterColumns(localized, showDate, { onStatus: setTarget })}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
emptyText={
|
||||
search ? 'No licences match that search.' : 'No licences issued yet.'
|
||||
}
|
||||
/>
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
type ApplicationDetail,
|
||||
} from '@ema-platform/api';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
type EntryKind = 'status' | 'remark' | 'upload' | 'assignment';
|
||||
|
||||
@@ -50,7 +51,8 @@ const ICONS: Record<EntryKind, typeof IconArrowRight> = {
|
||||
* notifications sent to the applicant are not among them.
|
||||
*/
|
||||
export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { t } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const entries = useMemo<ActivityEntry[]>(() => {
|
||||
const merged: ActivityEntry[] = [];
|
||||
@@ -69,9 +71,11 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
? t(`review.events.${history.event}`, {
|
||||
defaultValue: history.event,
|
||||
})
|
||||
: `${history.fromStatus ? STATUS_LABELS[history.fromStatus] : '—'} → ${
|
||||
STATUS_LABELS[history.toStatus]
|
||||
}`,
|
||||
: `${
|
||||
history.fromStatus
|
||||
? t(`queue.statusValues.${history.fromStatus}`, STATUS_LABELS[history.fromStatus])
|
||||
: '—'
|
||||
} → ${t(`queue.statusValues.${history.toStatus}`, STATUS_LABELS[history.toStatus])}`,
|
||||
detail: history.remark ?? undefined,
|
||||
color: STATUS_COLORS[history.toStatus],
|
||||
});
|
||||
@@ -159,12 +163,9 @@ export function ActivityRail({ detail }: { detail: ApplicationDetail }) {
|
||||
<Text size="xs" c="dimmed">
|
||||
·
|
||||
</Text>
|
||||
<Tooltip
|
||||
label={new Date(entry.at).toLocaleString(i18n.language)}
|
||||
withArrow
|
||||
>
|
||||
<Tooltip label={showDate(entry.at)} withArrow>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.at).toLocaleDateString(i18n.language)}
|
||||
{showDate(entry.at.slice(0, 10))}
|
||||
</Text>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
@@ -80,7 +80,7 @@ export function DecisionBar({
|
||||
{/* Left: where the application stands, and who has it. */}
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light" size="lg">
|
||||
{STATUS_LABELS[status]}
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
|
||||
{assigneeName && (
|
||||
|
||||
@@ -3,7 +3,6 @@ import {
|
||||
Alert,
|
||||
Button,
|
||||
Checkbox,
|
||||
Group,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@mantine/core';
|
||||
import { IconAlertTriangle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { ModalFooter } from '@ema-platform/ui';
|
||||
import type { ResolvedAction } from '../config/actions';
|
||||
|
||||
/** Reason codes offered per action. Free text is always available too. */
|
||||
@@ -290,7 +290,7 @@ export function DecisionConfirmModal({
|
||||
/>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
</Button>
|
||||
@@ -310,7 +310,7 @@ export function DecisionConfirmModal({
|
||||
>
|
||||
{t(action.labelKey)}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
useClearDocumentReviewMutation,
|
||||
useGetDocumentReviewsQuery,
|
||||
useLocalized,
|
||||
useReviewDocumentMutation,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
@@ -62,6 +63,7 @@ export function DocumentsTab({
|
||||
onFlagRemark,
|
||||
}: DocumentsTabProps) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [preview, setPreview] = useState<Attachment | null>(null);
|
||||
const [rejecting, setRejecting] = useState<Record<string, string>>({});
|
||||
|
||||
@@ -114,6 +116,7 @@ export function DocumentsTab({
|
||||
}
|
||||
|
||||
const uploadedKeys = new Set(attachments.map((a) => a.documentKey));
|
||||
const requirementByKey = new Map(requirements.map((r) => [r.key, r]));
|
||||
const mandatory = requirements.filter((r) => r.mode !== 'OPTIONAL');
|
||||
const missing = mandatory.filter((r) => !uploadedKeys.has(r.key));
|
||||
const completeness = mandatory.length
|
||||
@@ -148,7 +151,7 @@ export function DocumentsTab({
|
||||
<Alert mt="sm" color="orange" icon={<IconAlertCircle size={16} />} variant="light">
|
||||
<Text size="sm">
|
||||
{t('review.documents.missing', 'Not yet uploaded')}:{' '}
|
||||
{missing.map((r) => r.name.en ?? r.key).join(', ')}
|
||||
{missing.map((r) => localized(r.name) || r.key).join(', ')}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -162,49 +165,56 @@ export function DocumentsTab({
|
||||
return (
|
||||
<Paper withBorder p="md" key={attachment.id}>
|
||||
<Group justify="space-between" wrap="nowrap" align="flex-start">
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<Group gap="sm" wrap="nowrap" style={{ minWidth: 0, flex: 1 }}>
|
||||
<IconFileText size={20} stroke={1.6} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500}>
|
||||
{attachment.documentKey}
|
||||
</Text>
|
||||
<div style={{ minWidth: 0, flex: 1 }}>
|
||||
{/* Badges sit beside the name, not in the outer nowrap row —
|
||||
that row also has to fit six action buttons, so a long
|
||||
name plus badges there overflowed its box and the
|
||||
opaque badge painted over the bleeding text. Nested here
|
||||
with its own wrap, the name truncates cleanly instead. */}
|
||||
<Group gap={6} wrap="wrap" align="center">
|
||||
<Text size="sm" fw={500} truncate style={{ maxWidth: '100%' }}>
|
||||
{localized(requirementByKey.get(attachment.documentKey)?.name) || attachment.documentKey}
|
||||
</Text>
|
||||
{verdict && (
|
||||
<Tooltip
|
||||
label={
|
||||
verdict.reason ??
|
||||
t('review.documents.reviewedBy', {
|
||||
name: verdict.reviewedByName ?? '—',
|
||||
defaultValue: 'Reviewed by {{name}}',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{flagged && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.documents.flagged', 'Correction requested')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
<Text size="xs" c="dimmed" truncate>
|
||||
{file?.originalName ?? t('review.documents.noFile', 'No file')}
|
||||
</Text>
|
||||
</div>
|
||||
{verdict && (
|
||||
<Tooltip
|
||||
label={
|
||||
verdict.reason ??
|
||||
t('review.documents.reviewedBy', {
|
||||
name: verdict.reviewedByName ?? '—',
|
||||
defaultValue: 'Reviewed by {{name}}',
|
||||
})
|
||||
}
|
||||
>
|
||||
<Badge
|
||||
color={verdict.decision === 'ACCEPTED' ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
leftSection={
|
||||
verdict.decision === 'ACCEPTED' ? (
|
||||
<IconCheck size={11} />
|
||||
) : (
|
||||
<IconX size={11} />
|
||||
)
|
||||
}
|
||||
>
|
||||
{verdict.decision === 'ACCEPTED'
|
||||
? t('review.documents.accepted', 'Accepted')
|
||||
: t('review.documents.rejected', 'Rejected')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
)}
|
||||
{flagged && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
{t('review.documents.flagged', 'Correction requested')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
|
||||
<Group gap="xs" wrap="nowrap">
|
||||
@@ -387,7 +397,11 @@ export function DocumentsTab({
|
||||
onClose={() => setPreview(null)}
|
||||
position="right"
|
||||
size="xl"
|
||||
title={preview?.documentKey}
|
||||
title={
|
||||
preview
|
||||
? localized(requirementByKey.get(preview.documentKey)?.name) || preview.documentKey
|
||||
: ''
|
||||
}
|
||||
// Focus is trapped and returned so keyboard users are not dropped at
|
||||
// the top of the page when the drawer closes.
|
||||
trapFocus
|
||||
@@ -397,13 +411,13 @@ export function DocumentsTab({
|
||||
isPdf ? (
|
||||
<iframe
|
||||
src={previewFile.url}
|
||||
title={preview?.documentKey ?? 'document'}
|
||||
title={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||
style={{ width: '100%', height: '80vh', border: 'none' }}
|
||||
/>
|
||||
) : isImage ? (
|
||||
<img
|
||||
src={previewFile.url}
|
||||
alt={preview?.documentKey ?? 'document'}
|
||||
alt={preview?.documentKey ?? t('review.documents.previewFallback', 'document')}
|
||||
style={{ maxWidth: '100%' }}
|
||||
/>
|
||||
) : (
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconFileDescription,
|
||||
IconId,
|
||||
IconRubberStamp,
|
||||
IconShip,
|
||||
IconTruck,
|
||||
IconUsers,
|
||||
@@ -77,6 +80,36 @@ const PRESENTATION: Record<string, LicenseTypePresentation> = {
|
||||
icon: IconAnchor,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
SEAFARER_REGISTRATION: {
|
||||
key: 'SEAFARER_REGISTRATION',
|
||||
icon: IconId,
|
||||
// Person-centric: no company entity, no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
VESSEL_REGISTRATION: {
|
||||
key: 'VESSEL_REGISTRATION',
|
||||
icon: IconAnchor,
|
||||
detailSections: DEFAULT_SECTIONS,
|
||||
},
|
||||
VESSEL_OWNERSHIP_TRANSFER: {
|
||||
key: 'VESSEL_OWNERSHIP_TRANSFER',
|
||||
icon: IconArrowsExchange,
|
||||
// Transfers an existing registration between owners: no company entity,
|
||||
// no capital threshold, no staff roles.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_COC: {
|
||||
key: 'ENDORSEMENT_COC',
|
||||
icon: IconRubberStamp,
|
||||
// Person-centric, same as seafarer registration: no company entity, no
|
||||
// capital threshold, no staff roles, no inspection.
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
ENDORSEMENT_GOC: {
|
||||
key: 'ENDORSEMENT_GOC',
|
||||
icon: IconRubberStamp,
|
||||
detailSections: ['overview', 'documents'],
|
||||
},
|
||||
};
|
||||
|
||||
/** Falls back to a generic presentation so an unseeded type still renders. */
|
||||
@@ -111,10 +144,15 @@ export interface EligibilityRule {
|
||||
* pass/fail line means the rule, the figure it was checked against, and the
|
||||
* outcome are all on screen.
|
||||
*/
|
||||
/** Loose enough to accept i18next's real `t` structurally — see sla.ts for
|
||||
* why this can't just be typed as `TFunction`. */
|
||||
type Translate = (key: string, options?: unknown) => string;
|
||||
|
||||
export function evaluateEligibility(
|
||||
application: LicenseApplication,
|
||||
licenseType: LicenseType | undefined,
|
||||
locale: string,
|
||||
t: Translate,
|
||||
): EligibilityRule[] {
|
||||
const rules: EligibilityRule[] = [];
|
||||
|
||||
@@ -139,11 +177,18 @@ export function evaluateEligibility(
|
||||
|
||||
rules.push({
|
||||
id: 'capital-threshold',
|
||||
label: `Paid-up capital ≥ ${format(threshold)}`,
|
||||
label: t('review.eligibilityRule.capitalThreshold', {
|
||||
amount: format(threshold),
|
||||
defaultValue: 'Paid-up capital ≥ {{amount}}',
|
||||
}),
|
||||
actual:
|
||||
effective === undefined
|
||||
? 'Not recorded'
|
||||
: `${format(effective)}${verified === undefined ? ' (declared)' : ' (verified)'}`,
|
||||
? t('review.eligibilityRule.notRecorded', 'Not recorded')
|
||||
: `${format(effective)}${
|
||||
verified === undefined
|
||||
? t('review.eligibilityRule.declaredSuffix', ' (declared)')
|
||||
: t('review.eligibilityRule.verifiedSuffix', ' (verified)')
|
||||
}`,
|
||||
// An unverified declaration is not evidence, so it reads as unknown
|
||||
// rather than as a pass the officer never actually made.
|
||||
status:
|
||||
@@ -167,8 +212,10 @@ export function evaluateEligibility(
|
||||
].includes(application.status);
|
||||
rules.push({
|
||||
id: 'inspection',
|
||||
label: 'Physical inspection completed',
|
||||
actual: inspected ? 'Recorded' : 'Not yet recorded',
|
||||
label: t('review.eligibilityRule.inspectionCompleted', 'Physical inspection completed'),
|
||||
actual: inspected
|
||||
? t('review.eligibilityRule.recorded', 'Recorded')
|
||||
: t('review.eligibilityRule.notYetRecorded', 'Not yet recorded'),
|
||||
status: inspected ? 'pass' : 'unknown',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { STATUS_LABELS, type LicenseApplication } from '@ema-platform/api';
|
||||
import { STATUS_LABELS, localized, type LicenseApplication } from '@ema-platform/api';
|
||||
import { dateDisplayer } from '@ema-platform/shared';
|
||||
import { computeSla } from './sla';
|
||||
|
||||
/**
|
||||
@@ -21,21 +22,19 @@ const COLUMNS: Array<{
|
||||
{ header: 'Company', value: (a) => a.companyName },
|
||||
{ header: 'Trade name', value: (a) => a.tradeName },
|
||||
{ header: 'TIN', value: (a) => a.tinNumber },
|
||||
{ header: 'Licence type', value: (a) => a.licenseType?.name?.en ?? a.licenseTypeId },
|
||||
{ header: 'Licence type', value: (a, locale) => localized(a.licenseType?.name, locale) || a.licenseTypeId },
|
||||
{ header: 'Status', value: (a) => STATUS_LABELS[a.status] },
|
||||
{ header: 'Kind', value: (a) => a.kind },
|
||||
{ header: 'Assigned officer', value: (a) => a.assignedOfficerId },
|
||||
{
|
||||
header: 'Submitted',
|
||||
value: (a, locale) =>
|
||||
a.submittedAt ? new Date(a.submittedAt).toLocaleString(locale) : '',
|
||||
value: (a, locale) => (a.submittedAt ? dateDisplayer(a.submittedAt, locale) : ''),
|
||||
},
|
||||
{
|
||||
header: 'Decided',
|
||||
value: (a, locale) =>
|
||||
a.decidedAt ? new Date(a.decidedAt).toLocaleString(locale) : '',
|
||||
value: (a, locale) => (a.decidedAt ? dateDisplayer(a.decidedAt, locale) : ''),
|
||||
},
|
||||
{ header: 'SLA', value: (a) => computeSla(a).label },
|
||||
{ header: 'SLA', value: (a, locale) => computeSla(a, undefined, locale).label },
|
||||
{ header: 'Adjustment rounds', value: (a) => a.adjustmentRound },
|
||||
{ header: 'Declared capital', value: (a) => a.capitalAmountDeclared },
|
||||
{ header: 'Verified capital', value: (a) => a.capitalAmountVerified },
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import type { LicenseApplication } from "@ema-platform/api";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
|
||||
export function licenseQueueActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
claiming: boolean;
|
||||
onClaim: (id: string) => void;
|
||||
onOpen: (id: string) => void;
|
||||
},
|
||||
): AdvancedColumn<LicenseApplication> {
|
||||
return {
|
||||
header: "",
|
||||
label: t("queue.actionsColumn", "Actions"),
|
||||
align: "right",
|
||||
size: 140,
|
||||
cell: ({ row }) =>
|
||||
row.original.assignedOfficerId === null &&
|
||||
row.original.status === "SUBMITTED" ? (
|
||||
<Button
|
||||
size="xs"
|
||||
loading={handlers.claiming}
|
||||
onClick={() => handlers.onClaim(row.original.id)}
|
||||
>
|
||||
{t("queue.claim", "Claim")}
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
onClick={() => handlers.onOpen(row.original.id)}
|
||||
>
|
||||
{t("queue.review", "Review")}
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,78 +1,133 @@
|
||||
import { Badge, Button, Group, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { Dispatch, ReactNode, SetStateAction } from "react";
|
||||
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
|
||||
import type { TFunction } from "i18next";
|
||||
import {
|
||||
APPLICANT_NAME_TYPE_KEYS,
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
applicantOrCompanyName,
|
||||
localized,
|
||||
type LicenseApplication,
|
||||
} from '@ema-platform/api';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { computeSla } from '../../sla';
|
||||
type QueueFilter,
|
||||
} from "@ema-platform/api";
|
||||
import type { AdvancedColumn } from "@ema-platform/ui";
|
||||
import { dateDisplayer } from "@ema-platform/shared";
|
||||
import { computeSla } from "../../sla";
|
||||
|
||||
export function licenseQueueColumns(
|
||||
t: TFunction,
|
||||
locale: string,
|
||||
handlers: {
|
||||
claiming: boolean;
|
||||
onClaim: (app: LicenseApplication) => void;
|
||||
onOpen: (app: LicenseApplication) => void;
|
||||
opts: {
|
||||
typeCode: string | undefined;
|
||||
items: LicenseApplication[];
|
||||
selected: string[];
|
||||
setSelected: Dispatch<SetStateAction<string[]>>;
|
||||
allSelected: boolean;
|
||||
sortableHeader: (
|
||||
label: string,
|
||||
field: NonNullable<QueueFilter["sortBy"]>,
|
||||
) => ReactNode;
|
||||
},
|
||||
): AdvancedTableColumn<LicenseApplication>[] {
|
||||
): AdvancedColumn<LicenseApplication>[] {
|
||||
const { typeCode, items, selected, setSelected, allSelected, sortableHeader } =
|
||||
opts;
|
||||
return [
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: t('queue.number', 'App #'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
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}>
|
||||
{app.applicationNumber}
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: t('queue.company', 'Company'),
|
||||
sortable: true,
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tin',
|
||||
header: t('queue.tin', 'TIN'),
|
||||
render: (app) => (
|
||||
header: t("queue.tin", "TIN"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.tinNumber ?? '—'}
|
||||
{row.original.tinNumber ?? "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: t('queue.typeCol', 'Type'),
|
||||
render: (app) => <Text size="sm">{app.licenseType?.name?.en ?? '—'}</Text>,
|
||||
header: t("queue.typeCol", "Type"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{localized(row.original.licenseType?.name, locale) || "—"}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('queue.statusCol', 'Status'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
<Badge color={STATUS_COLORS[app.status]} variant="light">
|
||||
{STATUS_LABELS[app.status]}
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'submittedAt',
|
||||
header: t('queue.submitted', 'Submitted'),
|
||||
sortable: true,
|
||||
render: (app) => (
|
||||
header: sortableHeader(
|
||||
t("queue.submitted", "Submitted"),
|
||||
"submittedAt",
|
||||
),
|
||||
label: t("queue.submitted", "Submitted"),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{app.submittedAt ? new Date(app.submittedAt).toLocaleDateString(locale) : '—'}
|
||||
{dateDisplayer(row.original.submittedAt, locale)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'sla',
|
||||
header: t('queue.sla', 'Age / SLA'),
|
||||
render: (app) => {
|
||||
const sla = computeSla(app);
|
||||
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, locale, (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>
|
||||
@@ -83,24 +138,5 @@ export function licenseQueueColumns(
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Claim/Review are text buttons, so they stay a regular column rather
|
||||
// than being redesigned into AdvancedTable icon actions.
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (app) => (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
{app.assignedOfficerId === null && app.status === 'SUBMITTED' ? (
|
||||
<Button size="xs" loading={handlers.claiming} onClick={() => handlers.onClaim(app)}>
|
||||
{t('queue.claim', 'Claim')}
|
||||
</Button>
|
||||
) : (
|
||||
<Button size="xs" variant="light" onClick={() => handlers.onOpen(app)}>
|
||||
{t('queue.review', 'Review')}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useNavigate, useParams, useSearchParams } from 'react-router-dom';
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import { useNavigate, useParams, useSearchParams } from "react-router-dom";
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
@@ -19,21 +18,22 @@ import {
|
||||
Text,
|
||||
TextInput,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { useDebouncedValue } from '@mantine/hooks';
|
||||
} from "@mantine/core";
|
||||
import { useDebouncedValue } from "@mantine/hooks";
|
||||
import {
|
||||
IconAlertCircle,
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconSearch,
|
||||
IconSortAscending,
|
||||
IconSortDescending,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
} from "@tabler/icons-react";
|
||||
import { notifications } from "@mantine/notifications";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import {
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useClaimApplicationMutation,
|
||||
useGetAllApplicationsQuery,
|
||||
useGetAssignedToMeQuery,
|
||||
@@ -41,11 +41,17 @@ import {
|
||||
useGetQueueCountsQuery,
|
||||
useGetQueueQuery,
|
||||
useLazyExportApplicationsQuery,
|
||||
type LicenseApplication,
|
||||
type LicenseStatus,
|
||||
type QueueFilter,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, EmptyState, ErrorState } from '@ema-platform/ui';
|
||||
import { licenseQueueColumns } from './columns';
|
||||
} from "@ema-platform/api";
|
||||
import {
|
||||
AdvancedTable,
|
||||
EmptyState,
|
||||
ErrorState,
|
||||
AmharicDatePicker,
|
||||
type AdvancedColumn,
|
||||
} from "@ema-platform/ui";
|
||||
import {
|
||||
DEFAULT_VIEW,
|
||||
SAVED_VIEWS,
|
||||
@@ -54,30 +60,32 @@ import {
|
||||
searchParamsFromFilter,
|
||||
writeLastView,
|
||||
type SavedViewId,
|
||||
} from '../../queue-views';
|
||||
import { exportApplicationsCsv } from '../../export';
|
||||
import { setDensity } from '../../../../store/preferences.slice';
|
||||
import { useAppDispatch, useAppSelector } from '../../../../store/hooks';
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from '../../useQueueKeyboard';
|
||||
} from "../../queue-views";
|
||||
import { exportApplicationsCsv } from "../../export";
|
||||
import { setDensity } from "../../../../store/preferences.slice";
|
||||
import { useAppDispatch, useAppSelector } from "../../../../store/hooks";
|
||||
import { KEYBOARD_SHORTCUTS, useQueueKeyboard } from "../../useQueueKeyboard";
|
||||
import { licenseQueueColumns } from "./columns";
|
||||
import { licenseQueueActionsColumn } from "./actions";
|
||||
|
||||
const PAGE_SIZE = 25;
|
||||
const PAGE_SIZE = 10;
|
||||
const SEARCH_DEBOUNCE_MS = 300;
|
||||
|
||||
const ALL_STATUSES: LicenseStatus[] = [
|
||||
'SUBMITTED',
|
||||
'UNDER_REVIEW',
|
||||
'UNDER_EVALUATION',
|
||||
'RESUBMIT_REQUIRED',
|
||||
'INSPECTION_PENDING',
|
||||
'INSPECTION_COMPLETED',
|
||||
'ON_HOLD',
|
||||
'APPROVED',
|
||||
'PAYMENT_PENDING',
|
||||
'PAID',
|
||||
'PAYMENT_CONFIRMED',
|
||||
'CERTIFICATE_ISSUED',
|
||||
'COMPLETED',
|
||||
'REJECTED',
|
||||
"SUBMITTED",
|
||||
"UNDER_REVIEW",
|
||||
"UNDER_EVALUATION",
|
||||
"RESUBMIT_REQUIRED",
|
||||
"INSPECTION_PENDING",
|
||||
"INSPECTION_COMPLETED",
|
||||
"ON_HOLD",
|
||||
"APPROVED",
|
||||
"PAYMENT_PENDING",
|
||||
"PAID",
|
||||
"PAYMENT_CONFIRMED",
|
||||
"CERTIFICATE_ISSUED",
|
||||
"COMPLETED",
|
||||
"REJECTED",
|
||||
];
|
||||
|
||||
/**
|
||||
@@ -97,11 +105,12 @@ export function LicenseQueuePage() {
|
||||
const density = useAppSelector((state) => state.preferences.density);
|
||||
|
||||
const [view, setView] = useState<SavedViewId>(
|
||||
() => (searchParams.get('view') as SavedViewId) || readLastView(),
|
||||
() => (searchParams.get("view") as SavedViewId) || readLastView(),
|
||||
);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get('page')) || 1);
|
||||
const [page, setPage] = useState(() => Number(searchParams.get("page")) || 1);
|
||||
const [pageSize, setPageSize] = useState(PAGE_SIZE);
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get('q') ?? '');
|
||||
const [searchInput, setSearchInput] = useState(searchParams.get("q") ?? "");
|
||||
const [cursor, setCursor] = useState(0);
|
||||
const [helpOpen, setHelpOpen] = useState(false);
|
||||
const [debouncedSearch] = useDebouncedValue(searchInput, SEARCH_DEBOUNCE_MS);
|
||||
@@ -127,22 +136,42 @@ export function LicenseQueuePage() {
|
||||
...urlFilter,
|
||||
search: debouncedSearch || undefined,
|
||||
licenseTypeId: pinnedTypeId ?? urlFilter.licenseTypeId,
|
||||
take: PAGE_SIZE,
|
||||
skip: (page - 1) * PAGE_SIZE,
|
||||
// No explicit sort in the URL or view → newest submissions first, so
|
||||
// the queue opens showing what most needs attention rather than
|
||||
// whatever order the backend happens to return. "Mine" sorts by
|
||||
// claimedAt instead — officers care when they picked it up, not when
|
||||
// it was originally submitted.
|
||||
sortBy:
|
||||
urlFilter.sortBy ??
|
||||
(activeView.id === "mine" ? "submittedAt" : "submittedAt"),
|
||||
sortDir: urlFilter.sortDir ?? "DESC",
|
||||
take: pageSize,
|
||||
skip: (page - 1) * pageSize,
|
||||
}),
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page],
|
||||
[activeView, urlFilter, debouncedSearch, pinnedTypeId, page, pageSize],
|
||||
);
|
||||
|
||||
// One query per source; the two inactive ones are skipped, so switching
|
||||
// views costs a single request rather than keeping three in flight.
|
||||
const queueQuery = useGetQueueQuery(filter, { skip: activeView.source !== 'queue' });
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, { skip: activeView.source !== 'mine' });
|
||||
const allQuery = useGetAllApplicationsQuery(filter, { skip: activeView.source !== 'all' });
|
||||
const queueQuery = useGetQueueQuery(filter, {
|
||||
skip: activeView.source !== "queue",
|
||||
});
|
||||
const mineQuery = useGetAssignedToMeQuery(filter, {
|
||||
skip: activeView.source !== "mine",
|
||||
});
|
||||
const allQuery = useGetAllApplicationsQuery(filter, {
|
||||
skip: activeView.source !== "all",
|
||||
});
|
||||
const active =
|
||||
activeView.source === 'queue' ? queueQuery : activeView.source === 'mine' ? mineQuery : allQuery;
|
||||
activeView.source === "queue"
|
||||
? queueQuery
|
||||
: activeView.source === "mine"
|
||||
? mineQuery
|
||||
: allQuery;
|
||||
|
||||
const [claim, { isLoading: claiming }] = useClaimApplicationMutation();
|
||||
const [runExport, { isFetching: exporting }] = useLazyExportApplicationsQuery();
|
||||
const [runExport, { isFetching: exporting }] =
|
||||
useLazyExportApplicationsQuery();
|
||||
|
||||
/**
|
||||
* Exports every row the filter matches, not just the page on screen.
|
||||
@@ -151,24 +180,28 @@ export function LicenseQueuePage() {
|
||||
*/
|
||||
async function handleExport() {
|
||||
try {
|
||||
const result = await runExport({ ...filter, take: undefined, skip: undefined }).unwrap();
|
||||
const result = await runExport({
|
||||
...filter,
|
||||
take: undefined,
|
||||
skip: undefined,
|
||||
}).unwrap();
|
||||
exportApplicationsCsv(result.items, i18n.language);
|
||||
if (result.truncated) {
|
||||
notifications.show({
|
||||
color: 'yellow',
|
||||
title: t('queue.exportTruncated', 'Export truncated'),
|
||||
message: t('queue.exportTruncatedBody', {
|
||||
color: "yellow",
|
||||
title: t("queue.exportTruncated", "Export truncated"),
|
||||
message: t("queue.exportTruncatedBody", {
|
||||
exported: result.items.length,
|
||||
total: result.total,
|
||||
defaultValue:
|
||||
'Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.',
|
||||
"Exported the first {{exported}} of {{total}} rows. Narrow the filter for the rest.",
|
||||
}),
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.exportFailed', 'Export failed'),
|
||||
color: "red",
|
||||
title: t("queue.exportFailed", "Export failed"),
|
||||
message: extractErrorMessage(err),
|
||||
});
|
||||
}
|
||||
@@ -176,7 +209,6 @@ export function LicenseQueuePage() {
|
||||
|
||||
const items = active.data?.items ?? [];
|
||||
const total = active.data?.total ?? 0;
|
||||
const pageCount = Math.max(1, Math.ceil(total / PAGE_SIZE));
|
||||
|
||||
const updateUrl = useCallback(
|
||||
(next: Partial<QueueFilter>, nextView: SavedViewId, nextPage: number) => {
|
||||
@@ -201,9 +233,11 @@ export function LicenseQueuePage() {
|
||||
updateUrl(next, view, 1);
|
||||
};
|
||||
|
||||
const toggleSort = (field: NonNullable<QueueFilter['sortBy']>) => {
|
||||
const toggleSort = (field: NonNullable<QueueFilter["sortBy"]>) => {
|
||||
const dir =
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== 'DESC' ? 'DESC' : 'ASC';
|
||||
urlFilter.sortBy === field && urlFilter.sortDir !== "DESC"
|
||||
? "DESC"
|
||||
: "ASC";
|
||||
setFacet({ sortBy: field, sortDir: dir });
|
||||
};
|
||||
|
||||
@@ -211,20 +245,23 @@ export function LicenseQueuePage() {
|
||||
try {
|
||||
await claim(id).unwrap();
|
||||
notifications.show({
|
||||
color: 'teal',
|
||||
title: t('queue.claimed', 'Claimed'),
|
||||
message: t('queue.claimedBody', 'The application is now assigned to you.'),
|
||||
color: "teal",
|
||||
title: t("queue.claimed", "Claimed"),
|
||||
message: t(
|
||||
"queue.claimedBody",
|
||||
"The application is now assigned to you.",
|
||||
),
|
||||
});
|
||||
changeView('mine');
|
||||
changeView("mine");
|
||||
} catch (err) {
|
||||
// A 409 means another officer got there first — refresh so the queue
|
||||
// stops showing work that is no longer available.
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: t('queue.claimFailed', 'Could not claim'),
|
||||
color: "red",
|
||||
title: t("queue.claimFailed", "Could not claim"),
|
||||
message: extractErrorMessage(
|
||||
err,
|
||||
t('queue.claimRace', 'Another officer already claimed it.'),
|
||||
t("queue.claimRace", "Another officer already claimed it."),
|
||||
),
|
||||
});
|
||||
active.refetch();
|
||||
@@ -235,19 +272,22 @@ export function LicenseQueuePage() {
|
||||
const results = await Promise.allSettled(
|
||||
selected.map((id) => claim(id).unwrap()),
|
||||
);
|
||||
const claimed = results.filter((r) => r.status === 'fulfilled').length;
|
||||
const claimed = results.filter((r) => r.status === "fulfilled").length;
|
||||
const lost = results.length - claimed;
|
||||
notifications.show({
|
||||
color: lost ? 'yellow' : 'teal',
|
||||
title: t('queue.bulkClaimed', { count: claimed, defaultValue: '{{count}} claimed' }),
|
||||
color: lost ? "yellow" : "teal",
|
||||
title: t("queue.bulkClaimed", {
|
||||
count: claimed,
|
||||
defaultValue: "{{count}} claimed",
|
||||
}),
|
||||
// Partial success is the normal case in a shared queue, so it is
|
||||
// reported rather than swallowed or treated as total failure.
|
||||
message: lost
|
||||
? t('queue.bulkClaimPartial', {
|
||||
? t("queue.bulkClaimPartial", {
|
||||
count: lost,
|
||||
defaultValue: '{{count}} were already taken by another officer.',
|
||||
defaultValue: "{{count}} were already taken by another officer.",
|
||||
})
|
||||
: '',
|
||||
: "",
|
||||
});
|
||||
setSelected([]);
|
||||
active.refetch();
|
||||
@@ -256,31 +296,85 @@ export function LicenseQueuePage() {
|
||||
const cursorRow = items[cursor];
|
||||
useQueueKeyboard({
|
||||
enabled: !helpOpen,
|
||||
onNext: () => setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onNext: () =>
|
||||
setCursor((c) => Math.min(c + 1, Math.max(items.length - 1, 0))),
|
||||
onPrevious: () => setCursor((c) => Math.max(c - 1, 0)),
|
||||
onOpen: () => cursorRow && navigate(`/licence-review/${cursorRow.id}`),
|
||||
onClaim: () => {
|
||||
// Only unclaimed rows can be claimed; pressing c elsewhere is a no-op
|
||||
// rather than an error the officer has to read.
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null) handleClaim(cursorRow.id);
|
||||
if (cursorRow && cursorRow.assignedOfficerId === null)
|
||||
handleClaim(cursorRow.id);
|
||||
},
|
||||
onEscape: () => setSelected([]),
|
||||
onHelp: () => setHelpOpen(true),
|
||||
});
|
||||
|
||||
const allSelected = items.length > 0 && selected.length === items.length;
|
||||
const sortIcon =
|
||||
urlFilter.sortDir === "DESC" ? (
|
||||
<IconSortDescending size={13} />
|
||||
) : (
|
||||
<IconSortAscending size={13} />
|
||||
);
|
||||
|
||||
const hasFacets = Boolean(
|
||||
urlFilter.status?.length ||
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
urlFilter.licenseTypeId ||
|
||||
urlFilter.assignee ||
|
||||
urlFilter.submittedFrom ||
|
||||
debouncedSearch,
|
||||
);
|
||||
|
||||
const sortableHeader = (
|
||||
label: string,
|
||||
field: NonNullable<QueueFilter["sortBy"]>,
|
||||
) => (
|
||||
<Group
|
||||
gap={4}
|
||||
wrap="nowrap"
|
||||
style={{ cursor: "pointer" }}
|
||||
onClick={() => toggleSort(field)}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{urlFilter.sortBy === field && sortIcon}
|
||||
</Group>
|
||||
);
|
||||
|
||||
const columns: AdvancedColumn<LicenseApplication>[] = useMemo(
|
||||
() => [
|
||||
...licenseQueueColumns(t, i18n.language, {
|
||||
typeCode,
|
||||
items,
|
||||
selected,
|
||||
setSelected,
|
||||
allSelected,
|
||||
sortableHeader,
|
||||
}),
|
||||
licenseQueueActionsColumn(t, {
|
||||
claiming,
|
||||
onClaim: handleClaim,
|
||||
onOpen: (id) => navigate(`/licence-review/${id}`),
|
||||
}),
|
||||
],
|
||||
[
|
||||
t,
|
||||
i18n.language,
|
||||
urlFilter.sortBy,
|
||||
sortIcon,
|
||||
selected,
|
||||
allSelected,
|
||||
items,
|
||||
claiming,
|
||||
typeCode,
|
||||
],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : 'md'}>
|
||||
<Container size="xl" py="md" pb={selected.length ? 80 : "md"}>
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>{t('queue.title', 'Licence applications')}</Title>
|
||||
<Title order={3}>{t("queue.title", "Licence applications")}</Title>
|
||||
{typeCode && (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(`nav.type${typeCode}`, { defaultValue: typeCode })}
|
||||
@@ -288,18 +382,18 @@ export function LicenseQueuePage() {
|
||||
)}
|
||||
</div>
|
||||
<Group gap="xs">
|
||||
<Tooltip label={t('queue.refresh', 'Refresh')}>
|
||||
<ActionIcon variant="default" size="lg" onClick={() => active.refetch()}>
|
||||
<IconRefresh size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={density}
|
||||
onChange={(v) => dispatch(setDensity(v as 'comfortable' | 'compact'))}
|
||||
onChange={(v) =>
|
||||
dispatch(setDensity(v as "comfortable" | "compact"))
|
||||
}
|
||||
data={[
|
||||
{ label: t('queue.comfortable', 'Comfortable'), value: 'comfortable' },
|
||||
{ label: t('queue.compact', 'Compact'), value: 'compact' },
|
||||
{
|
||||
label: t("queue.comfortable", "Comfortable"),
|
||||
value: "comfortable",
|
||||
},
|
||||
{ label: t("queue.compact", "Compact"), value: "compact" },
|
||||
]}
|
||||
/>
|
||||
<Button
|
||||
@@ -309,13 +403,17 @@ export function LicenseQueuePage() {
|
||||
loading={exporting}
|
||||
disabled={total === 0}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
|
||||
{/* Saved views, counted. */}
|
||||
<Tabs value={view} onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)} mb="sm">
|
||||
<Tabs
|
||||
value={view}
|
||||
onChange={(v) => changeView((v as SavedViewId) ?? DEFAULT_VIEW)}
|
||||
mb="sm"
|
||||
>
|
||||
<Tabs.List>
|
||||
{SAVED_VIEWS.map((savedView) => (
|
||||
<Tabs.Tab
|
||||
@@ -323,7 +421,7 @@ export function LicenseQueuePage() {
|
||||
value={savedView.id}
|
||||
rightSection={
|
||||
counts?.[savedView.countKey] ? (
|
||||
<Badge size="xs" variant="light" circle>
|
||||
<Badge size="xs" variant="light">
|
||||
{counts[savedView.countKey]}
|
||||
</Badge>
|
||||
) : undefined
|
||||
@@ -339,17 +437,20 @@ export function LicenseQueuePage() {
|
||||
<Paper withBorder p="sm" mb="sm">
|
||||
<Group gap="sm" align="flex-end" wrap="wrap">
|
||||
<TextInput
|
||||
label={t('queue.search', 'Search')}
|
||||
placeholder={t('queue.searchPlaceholder', 'Company, TIN or number')}
|
||||
label={t("queue.search", "Search")}
|
||||
placeholder={t("queue.searchPlaceholder", "Company, TIN or number")}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={searchInput}
|
||||
onChange={(e) => setSearchInput(e.currentTarget.value)}
|
||||
w={240}
|
||||
/>
|
||||
<MultiSelect
|
||||
label={t('queue.status', 'Status')}
|
||||
placeholder={t('queue.anyStatus', 'Any')}
|
||||
data={ALL_STATUSES.map((s) => ({ value: s, label: STATUS_LABELS[s] }))}
|
||||
label={t("queue.status", "Status")}
|
||||
placeholder={t("queue.anyStatus", "Any")}
|
||||
data={ALL_STATUSES.map((s) => ({
|
||||
value: s,
|
||||
label: t(`queue.statusValues.${s}`, STATUS_LABELS[s]),
|
||||
}))}
|
||||
value={urlFilter.status ?? []}
|
||||
onChange={(v) => setFacet({ status: v as LicenseStatus[] })}
|
||||
clearable
|
||||
@@ -357,11 +458,11 @@ export function LicenseQueuePage() {
|
||||
/>
|
||||
{!typeCode && (
|
||||
<Select
|
||||
label={t('queue.type', 'Licence type')}
|
||||
placeholder={t('queue.anyType', 'Any')}
|
||||
label={t("queue.type", "Licence type")}
|
||||
placeholder={t("queue.anyType", "Any")}
|
||||
data={(licenseTypes?.items ?? []).map((type) => ({
|
||||
value: type.id,
|
||||
label: type.name.en ?? type.key,
|
||||
label: localized(type.name, i18n.language) || type.key,
|
||||
}))}
|
||||
value={urlFilter.licenseTypeId ?? null}
|
||||
onChange={(v) => setFacet({ licenseTypeId: v ?? undefined })}
|
||||
@@ -369,28 +470,30 @@ export function LicenseQueuePage() {
|
||||
w={220}
|
||||
/>
|
||||
)}
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedFrom', 'Submitted from')}
|
||||
value={urlFilter.submittedFrom ?? ''}
|
||||
onChange={(e) => setFacet({ submittedFrom: e.currentTarget.value || undefined })}
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedFrom", "Submitted from")}
|
||||
value={urlFilter.submittedFrom ?? ""}
|
||||
onChange={(v) => setFacet({ submittedFrom: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={170}
|
||||
/>
|
||||
<TextInput
|
||||
type="date"
|
||||
label={t('queue.submittedTo', 'Submitted to')}
|
||||
value={urlFilter.submittedTo ?? ''}
|
||||
onChange={(e) => setFacet({ submittedTo: e.currentTarget.value || undefined })}
|
||||
<AmharicDatePicker
|
||||
label={t("queue.submittedTo", "Submitted to")}
|
||||
value={urlFilter.submittedTo ?? ""}
|
||||
onChange={(v) => setFacet({ submittedTo: v || undefined })}
|
||||
dateFormat="date"
|
||||
w={170}
|
||||
/>
|
||||
{hasFacets && (
|
||||
<Button
|
||||
variant="subtle"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => {
|
||||
setSearchInput('');
|
||||
setSearchInput("");
|
||||
setSearchParams(new URLSearchParams(), { replace: true });
|
||||
}}
|
||||
>
|
||||
{t('queue.clearFilters', 'Clear')}
|
||||
{t("queue.clearFilters", "Clear")}
|
||||
</Button>
|
||||
)}
|
||||
</Group>
|
||||
@@ -407,7 +510,7 @@ export function LicenseQueuePage() {
|
||||
</Stack>
|
||||
) : active.isError ? (
|
||||
<ErrorState
|
||||
title={t('queue.errorTitle', 'Could not load the queue')}
|
||||
title={t("queue.errorTitle", "Could not load the queue")}
|
||||
description={extractErrorMessage(active.error)}
|
||||
onRetry={() => active.refetch()}
|
||||
icon={IconAlertCircle}
|
||||
@@ -416,67 +519,73 @@ export function LicenseQueuePage() {
|
||||
<EmptyState
|
||||
title={
|
||||
hasFacets
|
||||
? t('queue.emptyFiltered', 'No applications match these filters')
|
||||
: t('queue.empty', 'Nothing waiting here')
|
||||
? t(
|
||||
"queue.emptyFiltered",
|
||||
"No applications match these filters",
|
||||
)
|
||||
: t("queue.empty", "Nothing waiting here")
|
||||
}
|
||||
description={
|
||||
hasFacets
|
||||
? t('queue.emptyFilteredBody', 'Try widening or clearing the filters.')
|
||||
: t('queue.emptyBody', 'New applications will appear here as they are submitted.')
|
||||
? t(
|
||||
"queue.emptyFilteredBody",
|
||||
"Try widening or clearing the filters.",
|
||||
)
|
||||
: t(
|
||||
"queue.emptyBody",
|
||||
"New applications will appear here as they are submitted.",
|
||||
)
|
||||
}
|
||||
action={
|
||||
hasFacets
|
||||
? {
|
||||
label: t('queue.clearFilters', 'Clear'),
|
||||
onClick: () => setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
label: t("queue.clearFilters", "Clear"),
|
||||
onClick: () =>
|
||||
setSearchParams(new URLSearchParams(), { replace: true }),
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Group justify="flex-end" p="sm" pb={0}>
|
||||
<Text size="sm" c="dimmed">
|
||||
{t("queue.showing", {
|
||||
from: (page - 1) * pageSize + 1,
|
||||
to: Math.min(page * pageSize, total),
|
||||
total,
|
||||
defaultValue: "Showing {{from}}–{{to}} of {{total}}",
|
||||
})}
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={licenseQueueColumns(t, i18n.language, {
|
||||
claiming,
|
||||
onClaim: (app) => handleClaim(app.id),
|
||||
onOpen: (app) => navigate(`/licence-review/${app.id}`),
|
||||
})}
|
||||
columns={columns}
|
||||
data={items}
|
||||
rowKey={(app) => app.id}
|
||||
selection={{ selected, onChange: setSelected }}
|
||||
sort={{
|
||||
sortBy: urlFilter.sortBy,
|
||||
sortDir: urlFilter.sortDir === 'DESC' ? 'desc' : 'asc',
|
||||
onSort: (field) =>
|
||||
toggleSort(field as NonNullable<QueueFilter['sortBy']>),
|
||||
tableName={t("queue.title", "Licence applications")}
|
||||
itemCount={total}
|
||||
pageIndex={page - 1}
|
||||
onPageChange={(pageIndex) => {
|
||||
const next = pageIndex + 1;
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
}}
|
||||
pagination={{
|
||||
page,
|
||||
totalPages: pageCount,
|
||||
onPageChange: (next) => {
|
||||
setPage(next);
|
||||
updateUrl({}, view, next);
|
||||
},
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={(next) => {
|
||||
setPageSize(next);
|
||||
setPage(1);
|
||||
updateUrl({}, view, 1);
|
||||
}}
|
||||
minWidth={1100}
|
||||
verticalSpacing={density === 'compact' ? 4 : 'sm'}
|
||||
// Keyboard cursor. Marked with a left border rather than a
|
||||
// background so it stays distinguishable from row selection and
|
||||
// from hover.
|
||||
rowStyle={(app) =>
|
||||
app.id === cursorRow?.id
|
||||
? { boxShadow: 'inset 3px 0 0 var(--mantine-color-blue-6)' }
|
||||
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
|
||||
}
|
||||
/>
|
||||
<Text size="sm" c="dimmed" p="sm">
|
||||
{t('queue.showing', {
|
||||
from: (page - 1) * PAGE_SIZE + 1,
|
||||
to: Math.min(page * PAGE_SIZE, total),
|
||||
total,
|
||||
defaultValue: 'Showing {{from}}–{{to}} of {{total}}',
|
||||
})}
|
||||
</Text>
|
||||
</>
|
||||
)}
|
||||
</Card>
|
||||
@@ -484,7 +593,7 @@ export function LicenseQueuePage() {
|
||||
<Modal
|
||||
opened={helpOpen}
|
||||
onClose={() => setHelpOpen(false)}
|
||||
title={t('shortcuts.title', 'Keyboard shortcuts')}
|
||||
title={t("shortcuts.title", "Keyboard shortcuts")}
|
||||
size="sm"
|
||||
>
|
||||
<Stack gap="xs">
|
||||
@@ -504,18 +613,18 @@ export function LicenseQueuePage() {
|
||||
withBorder
|
||||
shadow="md"
|
||||
p="sm"
|
||||
style={{ position: 'sticky', bottom: 16, zIndex: 50 }}
|
||||
style={{ position: "sticky", bottom: 16, zIndex: 50 }}
|
||||
>
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={500}>
|
||||
{t('queue.selectedCount', {
|
||||
{t("queue.selectedCount", {
|
||||
count: selected.length,
|
||||
defaultValue: '{{count}} selected',
|
||||
defaultValue: "{{count}} selected",
|
||||
})}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button variant="subtle" onClick={() => setSelected([])}>
|
||||
{t('common.cancel', 'Cancel')}
|
||||
{t("common.cancel", "Cancel")}
|
||||
</Button>
|
||||
<Button
|
||||
variant="default"
|
||||
@@ -527,12 +636,12 @@ export function LicenseQueuePage() {
|
||||
)
|
||||
}
|
||||
>
|
||||
{t('queue.export', 'Export CSV')}
|
||||
{t("queue.export", "Export CSV")}
|
||||
</Button>
|
||||
<Button loading={claiming} onClick={handleBulkClaim}>
|
||||
{t('queue.bulkClaim', {
|
||||
{t("queue.bulkClaim", {
|
||||
count: selected.length,
|
||||
defaultValue: 'Claim {{count}}',
|
||||
defaultValue: "Claim {{count}}",
|
||||
})}
|
||||
</Button>
|
||||
</Group>
|
||||
|
||||
@@ -1,80 +1,76 @@
|
||||
import { Badge, Checkbox, Group, Text, TextInput } from '@mantine/core';
|
||||
import type { ReactNode } from 'react';
|
||||
import { Checkbox, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { ApplicationStaff } from '@ema-platform/api';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
export function reviewStaffColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
flags: Record<string, { remark: string }>;
|
||||
/** Bilingual role label resolved from the licence-type config. */
|
||||
roleName: (member: ApplicationStaff) => string;
|
||||
/** Evidence badges — needs an attachments query, so the page supplies it. */
|
||||
renderEvidence: (member: ApplicationStaff) => ReactNode;
|
||||
onToggleFlag: (member: ApplicationStaff) => void;
|
||||
onRemarkChange: (member: ApplicationStaff, remark: string) => void;
|
||||
},
|
||||
): AdvancedTableColumn<ApplicationStaff>[] {
|
||||
): AdvancedColumn<ApplicationStaff>[] {
|
||||
const { flags } = handlers;
|
||||
return [
|
||||
{
|
||||
key: 'roleKey',
|
||||
header: t('review.role', 'Role'),
|
||||
render: (member) => <Text size="xs">{member.roleKey}</Text>,
|
||||
cell: ({ row }) => <Text size="xs">{handlers.roleName(row.original)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
header: t('review.name', 'Name'),
|
||||
render: (member) => <Text size="sm">{member.fullName}</Text>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.fullName}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'evidence',
|
||||
header: t('review.evidence', 'Evidence'),
|
||||
render: (member) => (
|
||||
<Group gap={4}>
|
||||
{(member.documents ?? []).map((doc) => (
|
||||
<Badge key={doc.id} size="xs" variant="light">
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
))}
|
||||
</Group>
|
||||
),
|
||||
cell: ({ row }) => handlers.renderEvidence(row.original),
|
||||
},
|
||||
{
|
||||
// A person's papers are as returnable as a document or a form section:
|
||||
// an ERB certificate for the wrong person is a defect the applicant has
|
||||
// to fix, and until now the officer had to describe it under some
|
||||
// unrelated document.
|
||||
key: 'correction',
|
||||
header: t('review.correction', 'Correction'),
|
||||
width: 260,
|
||||
render: (member) => (
|
||||
<>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={Boolean(flags[member.id])}
|
||||
onChange={() => handlers.onToggleFlag(member)}
|
||||
/>
|
||||
{flags[member.id] && (
|
||||
<TextInput
|
||||
mt={6}
|
||||
size: 260,
|
||||
cell: ({ row }) => {
|
||||
const member = row.original;
|
||||
return (
|
||||
<>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
error={
|
||||
flags[member.id].remark.trim()
|
||||
? null
|
||||
: t('review.correctionRequired', 'Say what must be corrected')
|
||||
}
|
||||
value={flags[member.id].remark}
|
||||
onChange={(e) => {
|
||||
const remark = e.currentTarget.value;
|
||||
handlers.onRemarkChange(member, remark);
|
||||
}}
|
||||
label={t('review.needsCorrection', 'Needs correction')}
|
||||
checked={Boolean(flags[member.id])}
|
||||
onChange={() => handlers.onToggleFlag(member)}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
{flags[member.id] && (
|
||||
<TextInput
|
||||
mt={6}
|
||||
size="xs"
|
||||
withAsterisk
|
||||
placeholder={t(
|
||||
'review.correctionPlaceholder',
|
||||
'What must the applicant correct?',
|
||||
)}
|
||||
error={
|
||||
flags[member.id].remark.trim()
|
||||
? null
|
||||
: t('review.correctionRequired', 'Say what must be corrected')
|
||||
}
|
||||
value={flags[member.id].remark}
|
||||
onChange={(e) => {
|
||||
const remark = e.currentTarget.value;
|
||||
handlers.onRemarkChange(member, remark);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -39,6 +39,7 @@ import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useApproveDocumentsMutation,
|
||||
useAssignApplicationMutation,
|
||||
useCompleteReviewMutation,
|
||||
@@ -46,6 +47,7 @@ import {
|
||||
useEscalateApplicationMutation,
|
||||
useFinalApproveMutation,
|
||||
useGetApplicationForReviewQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetInspectionsQuery,
|
||||
useGetAssignableOfficersQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
@@ -57,7 +59,14 @@ import {
|
||||
useScheduleInspectionMutation,
|
||||
type RemarkTargetType,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, ErrorState } from '@ema-platform/ui';
|
||||
import {
|
||||
AdvancedTable,
|
||||
AmharicDatePicker,
|
||||
ErrorState,
|
||||
ModalFooter,
|
||||
useServerTable,
|
||||
} from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { useAppSelector } from '../../../../store/hooks';
|
||||
import { DecisionBar } from '../../components/DecisionBar';
|
||||
@@ -80,10 +89,10 @@ type FlagMap = Record<string, { targetType: RemarkTargetType; remark: string }>;
|
||||
* that fills it in.
|
||||
*/
|
||||
const INSPECTION_CHECKLIST_ITEMS = [
|
||||
{ key: 'office_premises', label: 'Office premises' },
|
||||
{ key: 'storage_facilities', label: 'Warehouse / storage facilities' },
|
||||
{ key: 'vehicles_equipment', label: 'Vehicles / equipment' },
|
||||
{ key: 'safety_compliance', label: 'Safety & regulatory compliance' },
|
||||
{ key: 'office_premises', labelKey: 'review.checklist.officePremises', fallback: 'Office premises' },
|
||||
{ key: 'storage_facilities', labelKey: 'review.checklist.storageFacilities', fallback: 'Warehouse / storage facilities' },
|
||||
{ key: 'vehicles_equipment', labelKey: 'review.checklist.vehiclesEquipment', fallback: 'Vehicles / equipment' },
|
||||
{ key: 'safety_compliance', labelKey: 'review.checklist.safetyCompliance', fallback: 'Safety & regulatory compliance' },
|
||||
] as const;
|
||||
|
||||
function buildChecklist(
|
||||
@@ -91,7 +100,9 @@ function buildChecklist(
|
||||
) {
|
||||
return INSPECTION_CHECKLIST_ITEMS.map((item) => ({
|
||||
key: item.key,
|
||||
label: item.label,
|
||||
// Persisted as-is (stable English), not the officer's display language —
|
||||
// this is an audit record, not UI text.
|
||||
label: item.fallback,
|
||||
// Untouched rows default to PASS — the segmented control shows exactly
|
||||
// that, so what the officer saw is what gets recorded.
|
||||
outcome: outcomes[item.key] ?? 'PASS',
|
||||
@@ -109,6 +120,8 @@ function buildChecklist(
|
||||
*/
|
||||
export function LicenseReviewPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const { id = '' } = useParams();
|
||||
const { can } = usePermissions();
|
||||
const currentUserId = useAppSelector((state) => state.auth.user?.id) ?? '';
|
||||
@@ -123,6 +136,13 @@ export function LicenseReviewPage() {
|
||||
{ idOrKey: data?.application.licenseTypeId ?? '', kind: data?.application.kind ?? 'NEW' },
|
||||
{ skip: !data?.application.licenseTypeId },
|
||||
);
|
||||
// Staff role names are already bilingual on the config — the wire data only
|
||||
// carries the role key (e.g. 'CAPTAIN'), so this is what turns it back into
|
||||
// the label an officer reads.
|
||||
const roleNameByKey = useMemo(
|
||||
() => new Map(requirements?.staffRoleRequirements.map((r) => [r.roleKey, r.name]) ?? []),
|
||||
[requirements],
|
||||
);
|
||||
|
||||
const [completeReview] = useCompleteReviewMutation();
|
||||
const [requestAdjustment] = useRequestAdjustmentMutation();
|
||||
@@ -140,6 +160,7 @@ export function LicenseReviewPage() {
|
||||
// silently reassigning to whoever already held the application.
|
||||
const { data: officers = [] } = useGetAssignableOfficersQuery();
|
||||
|
||||
const staffTable = useServerTable();
|
||||
const [flags, setFlags] = useState<FlagMap>({});
|
||||
const [capital, setCapital] = useState<number | undefined>();
|
||||
const [pendingAction, setPendingAction] = useState<ResolvedAction | null>(null);
|
||||
@@ -201,7 +222,7 @@ export function LicenseReviewPage() {
|
||||
return {
|
||||
key,
|
||||
label: member
|
||||
? `${member.roleKey} — ${member.fullName}`
|
||||
? `${localized(roleNameByKey.get(member.roleKey)) || member.roleKey} — ${member.fullName}`
|
||||
: t('review.staffMember', 'Staff member'),
|
||||
};
|
||||
}
|
||||
@@ -211,7 +232,7 @@ export function LicenseReviewPage() {
|
||||
return { key, label: key };
|
||||
}),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[flags, data?.staff, t],
|
||||
[flags, data?.staff, t, localized, roleNameByKey],
|
||||
);
|
||||
|
||||
const actions = useMemo(() => {
|
||||
@@ -267,9 +288,12 @@ export function LicenseReviewPage() {
|
||||
|
||||
const app = data.application;
|
||||
const status = app.status;
|
||||
const staffPaged = staffTable.paginate(data.staff);
|
||||
const presentation = presentationFor(app.licenseType?.key);
|
||||
const sla = computeSla(app);
|
||||
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
||||
const sla = computeSla(app, undefined, i18n.language, (key, options) => t(key, options as any) as string);
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any -- i18next's overloaded TFunction type doesn't structurally match a plain callback signature
|
||||
const eligibility = evaluateEligibility(app, app.licenseType, i18n.language, (key, options) => t(key, options as any) as string);
|
||||
|
||||
const rawThreshold = app.licenseType?.capitalThreshold;
|
||||
const threshold =
|
||||
@@ -483,6 +507,12 @@ export function LicenseReviewPage() {
|
||||
|
||||
const sections = presentation.detailSections;
|
||||
const formSections = Object.entries(app.formData ?? {});
|
||||
// The bilingual section/field labels the applicant's wizard renders — this
|
||||
// page already fetches them (`requirements` above) but used to fall back to
|
||||
// the raw formData keys, so an officer saw `vesselId` instead of a label in
|
||||
// either language.
|
||||
const configSections = requirements?.licenseType.formSchema.sections ?? [];
|
||||
const sectionsByKey = new Map(configSections.map((s) => [s.key, s]));
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -494,7 +524,7 @@ export function LicenseReviewPage() {
|
||||
{app.applicationNumber}
|
||||
</Text>
|
||||
<Badge color={STATUS_COLORS[status]} variant="light">
|
||||
{STATUS_LABELS[status]}
|
||||
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
|
||||
</Badge>
|
||||
{app.adjustmentRound > 0 && (
|
||||
<Badge color="orange" variant="light" size="sm">
|
||||
@@ -531,16 +561,12 @@ export function LicenseReviewPage() {
|
||||
{t('review.summary', 'Summary')}
|
||||
</Text>
|
||||
<Stack gap={6}>
|
||||
<SummaryRow label={t('review.type', 'Type')} value={app.licenseType?.name?.en} />
|
||||
<SummaryRow label={t('review.type', 'Type')} value={localized(app.licenseType?.name)} />
|
||||
<SummaryRow label={t('review.tin', 'TIN')} value={app.tinNumber} />
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={app.kind} />
|
||||
<SummaryRow label={t('review.kind', 'Kind')} value={t(`review.kindValues.${app.kind}`, app.kind)} />
|
||||
<SummaryRow
|
||||
label={t('review.submitted', 'Submitted')}
|
||||
value={
|
||||
app.submittedAt
|
||||
? new Date(app.submittedAt).toLocaleDateString(i18n.language)
|
||||
: undefined
|
||||
}
|
||||
value={showDate(app.submittedAt)}
|
||||
/>
|
||||
<SummaryRow label={t('review.slaLabel', 'SLA')} value={sla.label} />
|
||||
</Stack>
|
||||
@@ -599,12 +625,12 @@ export function LicenseReviewPage() {
|
||||
key={entry.id}
|
||||
title={
|
||||
<Text size="xs" fw={600}>
|
||||
{STATUS_LABELS[entry.toStatus] ?? entry.toStatus}
|
||||
{t(`queue.statusValues.${entry.toStatus}`, STATUS_LABELS[entry.toStatus] ?? entry.toStatus)}
|
||||
</Text>
|
||||
}
|
||||
>
|
||||
<Text size="xs" c="dimmed">
|
||||
{new Date(entry.createdAt).toLocaleDateString(i18n.language)}
|
||||
{showDate(entry.createdAt)}
|
||||
</Text>
|
||||
</Timeline.Item>
|
||||
))}
|
||||
@@ -641,11 +667,18 @@ export function LicenseReviewPage() {
|
||||
|
||||
<Tabs.Panel value="overview">
|
||||
<Stack>
|
||||
{formSections.map(([sectionKey, values]) => (
|
||||
{formSections.map(([sectionKey, values]) => {
|
||||
const sectionConfig = sectionsByKey.get(sectionKey);
|
||||
const fieldsByKey = new Map(
|
||||
(sectionConfig?.fields ?? []).map((f) => [f.key, f]),
|
||||
);
|
||||
return (
|
||||
<Card withBorder key={sectionKey} padding="md">
|
||||
<Group justify="space-between" mb="xs">
|
||||
<Text fw={600} size="sm" tt="capitalize">
|
||||
{sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
{sectionConfig
|
||||
? localized(sectionConfig.title)
|
||||
: sectionKey.replace(/([A-Z])/g, ' $1')}
|
||||
</Text>
|
||||
<Checkbox
|
||||
size="xs"
|
||||
@@ -656,18 +689,21 @@ export function LicenseReviewPage() {
|
||||
</Group>
|
||||
<Table withTableBorder>
|
||||
<Table.Tbody>
|
||||
{Object.entries(values ?? {}).map(([k, v]) => (
|
||||
{Object.entries(values ?? {}).map(([k, v]) => {
|
||||
const fieldConfig = fieldsByKey.get(k);
|
||||
return (
|
||||
<Table.Tr key={k}>
|
||||
<Table.Td w="40%">
|
||||
<Text size="xs" c="dimmed">
|
||||
{k}
|
||||
{fieldConfig ? localized(fieldConfig.label) : k}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Text size="sm">{v === null ? '—' : String(v)}</Text>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
{flags[sectionKey] && (
|
||||
@@ -704,7 +740,8 @@ export function LicenseReviewPage() {
|
||||
/>
|
||||
)}
|
||||
</Card>
|
||||
))}
|
||||
);
|
||||
})}
|
||||
</Stack>
|
||||
</Tabs.Panel>
|
||||
|
||||
@@ -764,22 +801,29 @@ export function LicenseReviewPage() {
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="staff">
|
||||
<Card withBorder padding="md">
|
||||
<AdvancedTable
|
||||
columns={reviewStaffColumns(t, {
|
||||
flags,
|
||||
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
|
||||
onRemarkChange: (member, remark) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[member.id]: { ...p[member.id], remark },
|
||||
})),
|
||||
})}
|
||||
data={data.staff}
|
||||
rowKey={(member) => member.id}
|
||||
onRefresh={refetch}
|
||||
/>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
tableName={t('review.tabs.staff', 'Staff')}
|
||||
columns={reviewStaffColumns(t, {
|
||||
flags,
|
||||
roleName: (member) =>
|
||||
localized(roleNameByKey.get(member.roleKey)) || member.roleKey,
|
||||
renderEvidence: (member) => (
|
||||
<StaffEvidenceCell staffId={member.id} fallback={member.documents} />
|
||||
),
|
||||
onToggleFlag: (member) => toggleFlag('STAFF', member.id),
|
||||
onRemarkChange: (member, remark) =>
|
||||
setFlags((p) => ({
|
||||
...p,
|
||||
[member.id]: { ...p[member.id], remark },
|
||||
})),
|
||||
})}
|
||||
data={staffPaged.rows}
|
||||
itemCount={staffPaged.itemCount}
|
||||
pageIndex={staffPaged.pageIndex}
|
||||
onPageChange={staffTable.setPageIndex}
|
||||
pageSize={staffTable.pageSize}
|
||||
refresh={refetch}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="inspection">
|
||||
@@ -795,7 +839,7 @@ export function LicenseReviewPage() {
|
||||
<div>
|
||||
<Text size="sm">
|
||||
{inspection.scheduledDate
|
||||
? new Date(inspection.scheduledDate).toLocaleString(i18n.language)
|
||||
? showDate(inspection.scheduledDate)
|
||||
: t('review.unscheduled', 'Not scheduled')}
|
||||
</Text>
|
||||
{inspection.findings && (
|
||||
@@ -808,7 +852,11 @@ export function LicenseReviewPage() {
|
||||
variant="light"
|
||||
color={inspection.result === 'FAILED' ? 'red' : 'teal'}
|
||||
>
|
||||
{inspection.result ?? inspection.status}
|
||||
{inspection.result === 'PASSED'
|
||||
? t('review.passed', 'Passed')
|
||||
: inspection.result === 'FAILED'
|
||||
? t('review.failed', 'Failed')
|
||||
: t(`review.inspectionStatus.${inspection.status}`, inspection.status)}
|
||||
</Badge>
|
||||
</Group>
|
||||
))}
|
||||
@@ -863,13 +911,13 @@ export function LicenseReviewPage() {
|
||||
title={t('review.actions.scheduleInspection', 'Schedule inspection')}
|
||||
>
|
||||
<Stack>
|
||||
<TextInput
|
||||
type="datetime-local"
|
||||
<AmharicDatePicker
|
||||
label={t('review.dateTime', 'Date and time')}
|
||||
value={inspectionDate}
|
||||
onChange={(e) => setInspectionDate(e.currentTarget.value)}
|
||||
onChange={setInspectionDate}
|
||||
withTime
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Tooltip
|
||||
label={t('review.pickDate', 'Pick a date and time first')}
|
||||
disabled={Boolean(inspectionDate)}
|
||||
@@ -891,7 +939,7 @@ export function LicenseReviewPage() {
|
||||
run(async () => {
|
||||
await scheduleInspection({
|
||||
applicationId: id,
|
||||
scheduledDate: new Date(inspectionDate).toISOString(),
|
||||
scheduledDate: inspectionDate,
|
||||
}).unwrap();
|
||||
setInspectionOpen(false);
|
||||
}, t('review.done.scheduled', 'Inspection scheduled'))
|
||||
@@ -899,7 +947,7 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<IconCheck size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
@@ -914,7 +962,7 @@ export function LicenseReviewPage() {
|
||||
<Stack gap={6}>
|
||||
{INSPECTION_CHECKLIST_ITEMS.map((item) => (
|
||||
<Group key={item.key} justify="space-between" wrap="nowrap">
|
||||
<Text size="sm">{item.label}</Text>
|
||||
<Text size="sm">{t(item.labelKey, item.fallback)}</Text>
|
||||
<SegmentedControl
|
||||
size="xs"
|
||||
value={checklist[item.key] ?? 'PASS'}
|
||||
@@ -947,7 +995,7 @@ export function LicenseReviewPage() {
|
||||
autosize
|
||||
minRows={3}
|
||||
/>
|
||||
<Group grow>
|
||||
<ModalFooter grow>
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
color="teal"
|
||||
@@ -996,13 +1044,58 @@ export function LicenseReviewPage() {
|
||||
>
|
||||
<IconX size={18} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Evidence badges for one staff member.
|
||||
*
|
||||
* `application-for-review` nests a `documents` array per staff member, but it
|
||||
* doesn't always carry the uploaded file (the portal's own upload widget
|
||||
* hits the attachments endpoint directly for the same reason). Query
|
||||
* attachments by owner here too, so the officer gets a working link instead
|
||||
* of a badge with nowhere to go.
|
||||
*/
|
||||
function StaffEvidenceCell({
|
||||
staffId,
|
||||
fallback,
|
||||
}: {
|
||||
staffId: string;
|
||||
fallback?: { id: string; documentKey: string; files: { url?: string }[] }[];
|
||||
}) {
|
||||
const { data: attachments } = useGetAttachmentsQuery({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
ownerId: staffId,
|
||||
});
|
||||
const docs = attachments?.length ? attachments : (fallback ?? []);
|
||||
|
||||
return (
|
||||
<Group gap={4}>
|
||||
{docs.map((doc) => {
|
||||
const url = doc.files?.[0]?.url;
|
||||
return (
|
||||
<Badge
|
||||
key={doc.id}
|
||||
size="xs"
|
||||
variant="light"
|
||||
component={url ? 'a' : undefined}
|
||||
href={url}
|
||||
target={url ? '_blank' : undefined}
|
||||
rel={url ? 'noreferrer' : undefined}
|
||||
style={url ? { cursor: 'pointer' } : undefined}
|
||||
>
|
||||
{doc.documentKey}
|
||||
</Badge>
|
||||
);
|
||||
})}
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
function SummaryRow({ label, value }: { label: string; value?: string | null }) {
|
||||
return (
|
||||
<Group justify="space-between" gap="xs" wrap="nowrap">
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
import { dateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/** Amber once this much of the window has been consumed. */
|
||||
const WARNING_RATIO = 0.7;
|
||||
@@ -17,6 +18,19 @@ export interface SlaState {
|
||||
ratio: number;
|
||||
}
|
||||
|
||||
/** Loose enough to accept i18next's real `t` structurally; callers pass that
|
||||
* one, and a plain function falls back to English by resolving
|
||||
* `defaultValue` itself, so `computeSla` still works with no i18n context
|
||||
* (the CSV export). */
|
||||
export type SlaTranslate = (key: string, options?: unknown) => string;
|
||||
|
||||
function tr(t: SlaTranslate | undefined, key: string, options: Record<string, unknown> | string): string {
|
||||
if (t) return t(key, options);
|
||||
if (typeof options === 'string') return options;
|
||||
const template = String(options['defaultValue'] ?? '');
|
||||
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(options[name] ?? ''));
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const hours = Math.floor(Math.abs(ms) / HOUR_MS);
|
||||
if (hours < 1) return '<1h';
|
||||
@@ -35,6 +49,8 @@ function formatDuration(ms: number): string {
|
||||
export function computeSla(
|
||||
application: LicenseApplication,
|
||||
now: number = Date.now(),
|
||||
language = 'en',
|
||||
t?: SlaTranslate,
|
||||
): SlaState {
|
||||
const slaHours = application.licenseType?.slaHours;
|
||||
const submittedAt = application.submittedAt;
|
||||
@@ -44,7 +60,7 @@ export function computeSla(
|
||||
state: 'untracked',
|
||||
color: 'gray',
|
||||
label: '—',
|
||||
tooltip: 'No turnaround target is set for this licence type.',
|
||||
tooltip: tr(t, 'review.sla.untracked', 'No turnaround target is set for this licence type.'),
|
||||
ratio: 0,
|
||||
};
|
||||
}
|
||||
@@ -54,15 +70,23 @@ export function computeSla(
|
||||
const elapsed = (application.decidedAt ? new Date(application.decidedAt).getTime() : now) - submitted;
|
||||
const window = slaHours * HOUR_MS;
|
||||
const ratio = Math.min(Math.max(elapsed / window, 0), 1);
|
||||
const targetText = `Target ${slaHours}h from submission (${new Date(target).toLocaleString()})`;
|
||||
const targetText = tr(t, 'review.sla.target', {
|
||||
hours: slaHours,
|
||||
date: dateDisplayer(target, language),
|
||||
defaultValue: 'Target {{hours}}h from submission ({{date}})',
|
||||
});
|
||||
|
||||
if (application.decidedAt) {
|
||||
const met = elapsed <= window;
|
||||
return {
|
||||
state: 'decided',
|
||||
color: met ? 'teal' : 'gray',
|
||||
label: met ? 'Met' : 'Missed',
|
||||
tooltip: `Decided in ${formatDuration(elapsed)}. ${targetText}`,
|
||||
label: tr(t, met ? 'review.sla.met' : 'review.sla.missed', met ? 'Met' : 'Missed'),
|
||||
tooltip: tr(t, 'review.sla.decidedIn', {
|
||||
duration: formatDuration(elapsed),
|
||||
target: targetText,
|
||||
defaultValue: 'Decided in {{duration}}. {{target}}',
|
||||
}),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
@@ -72,8 +96,15 @@ export function computeSla(
|
||||
return {
|
||||
state: 'breached',
|
||||
color: 'red',
|
||||
label: `Overdue ${formatDuration(remaining)}`,
|
||||
tooltip: `Overdue by ${formatDuration(remaining)}. ${targetText}`,
|
||||
label: tr(t, 'review.sla.overdue', {
|
||||
duration: formatDuration(remaining),
|
||||
defaultValue: 'Overdue {{duration}}',
|
||||
}),
|
||||
tooltip: tr(t, 'review.sla.overdueBy', {
|
||||
duration: formatDuration(remaining),
|
||||
target: targetText,
|
||||
defaultValue: 'Overdue by {{duration}}. {{target}}',
|
||||
}),
|
||||
ratio: 1,
|
||||
};
|
||||
}
|
||||
@@ -82,8 +113,15 @@ export function computeSla(
|
||||
return {
|
||||
state: used >= WARNING_RATIO ? 'warning' : 'ok',
|
||||
color: used >= WARNING_RATIO ? 'yellow' : 'teal',
|
||||
label: `${formatDuration(remaining)} left`,
|
||||
tooltip: `${formatDuration(remaining)} remaining. ${targetText}`,
|
||||
label: tr(t, 'review.sla.left', {
|
||||
duration: formatDuration(remaining),
|
||||
defaultValue: '{{duration}} left',
|
||||
}),
|
||||
tooltip: tr(t, 'review.sla.remaining', {
|
||||
duration: formatDuration(remaining),
|
||||
target: targetText,
|
||||
defaultValue: '{{duration}} remaining. {{target}}',
|
||||
}),
|
||||
ratio,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
Box,
|
||||
rem,
|
||||
Center,
|
||||
useMantineColorScheme,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconChevronRight,
|
||||
@@ -21,6 +22,7 @@ import {
|
||||
import { useGetLocationsQuery } from '../api/location-api';
|
||||
import type { Location } from '../types/location';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useLocalized } from '@ema-platform/api';
|
||||
|
||||
interface LocationTreeProps {
|
||||
selectedId: string | null;
|
||||
@@ -50,10 +52,15 @@ function TreeNode({
|
||||
onSelect: (location: Location) => void;
|
||||
depth: number;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const [opened, setOpened] = useState(depth < 1);
|
||||
const isSelected = selectedId === location.id;
|
||||
const hasChildren =
|
||||
Array.isArray(location.children) && location.children.length > 0;
|
||||
const { colorScheme } = useMantineColorScheme();
|
||||
const hoverBg = colorScheme === 'dark'
|
||||
? 'var(--mantine-color-dark-6)'
|
||||
: 'var(--mantine-color-gray-0)';
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setOpened((prev) => !prev);
|
||||
@@ -88,8 +95,7 @@ function TreeNode({
|
||||
}}
|
||||
onMouseEnter={(e) => {
|
||||
if (!isSelected)
|
||||
e.currentTarget.style.backgroundColor =
|
||||
'var(--mantine-color-gray-0)';
|
||||
e.currentTarget.style.backgroundColor = hoverBg;
|
||||
}}
|
||||
onMouseLeave={(e) => {
|
||||
if (!isSelected)
|
||||
@@ -130,7 +136,7 @@ function TreeNode({
|
||||
style={{ flexShrink: 0, opacity: 0.6 }}
|
||||
/>
|
||||
<Text size="sm" truncate style={{ flex: 1 }}>
|
||||
{location.names.en}
|
||||
{localized(location.names)}
|
||||
</Text>
|
||||
</UnstyledButton>
|
||||
{hasChildren && (
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { ActionIcon, Group, Tooltip } from '@mantine/core';
|
||||
import { IconEdit, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableAction } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { LocationType } from '../../types/location';
|
||||
|
||||
export function locationTypeColumnActions(
|
||||
@@ -9,21 +10,34 @@ export function locationTypeColumnActions(
|
||||
onEdit: (type: LocationType) => void;
|
||||
onDelete: (type: LocationType) => void;
|
||||
},
|
||||
): AdvancedTableAction<LocationType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'edit',
|
||||
label: t('location.edit'),
|
||||
color: 'blue',
|
||||
icon: <IconEdit size={14} />,
|
||||
onClick: handlers.onEdit,
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
label: t('location.delete'),
|
||||
color: 'red',
|
||||
icon: <IconTrash size={14} />,
|
||||
onClick: handlers.onDelete,
|
||||
},
|
||||
];
|
||||
): AdvancedColumn<LocationType> {
|
||||
return {
|
||||
header: '',
|
||||
label: t('location.actions', 'Actions'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Group gap="xs" wrap="nowrap" justify="flex-end">
|
||||
<Tooltip label={t('location.edit')}>
|
||||
<ActionIcon
|
||||
color="blue"
|
||||
variant="subtle"
|
||||
aria-label={t('location.edit')}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label={t('location.delete')}>
|
||||
<ActionIcon
|
||||
color="red"
|
||||
variant="subtle"
|
||||
aria-label={t('location.delete')}
|
||||
onClick={() => handlers.onDelete(row.original)}
|
||||
>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,35 +1,32 @@
|
||||
import { Badge } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { LocationType } from '../../types/location';
|
||||
|
||||
export function locationTypeColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
): AdvancedTableColumn<LocationType>[] {
|
||||
): AdvancedColumn<LocationType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'level',
|
||||
header: t('location.level'),
|
||||
render: (type) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{type.level}
|
||||
{row.original.level}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'code',
|
||||
header: t('location.code'),
|
||||
render: (type) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color="blue">
|
||||
{type.code}
|
||||
{row.original.code}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: t('location.name'),
|
||||
render: (type) => type.names[locale],
|
||||
cell: ({ row }) => row.original.names[locale],
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
useUpdateLocationTypeMutation,
|
||||
useDeleteLocationTypeMutation,
|
||||
} from '../../api/location-api';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { locationTypeColumns } from './columns';
|
||||
import { locationTypeColumnActions } from './actions';
|
||||
|
||||
@@ -31,7 +31,9 @@ interface LocationTypeFormValues {
|
||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypes, isLoading, refetch } = useGetLocationTypesQuery();
|
||||
const table = useServerTable();
|
||||
const [createType] = useCreateLocationTypeMutation();
|
||||
const [updateType] = useUpdateLocationTypeMutation();
|
||||
const [deleteType] = useDeleteLocationTypeMutation();
|
||||
@@ -81,8 +83,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
try {
|
||||
await deleteType(id).unwrap();
|
||||
notify.success(t('location.typeDeleted'));
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,14 +98,15 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
notify.success(t('location.typeCreated'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('location.typeError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
const sortedTypes = locationTypes?.items
|
||||
? [...locationTypes.items].sort((a, b) => a.level - b.level)
|
||||
: [];
|
||||
const paged = table.paginate(sortedTypes);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -169,16 +172,22 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
<Divider mb="md" />
|
||||
|
||||
<AdvancedTable
|
||||
columns={locationTypeColumns(t, locale)}
|
||||
data={sortedTypes}
|
||||
rowKey={(type) => type.id}
|
||||
actions={locationTypeColumnActions(t, {
|
||||
onEdit: handleEdit,
|
||||
onDelete: (type) => handleDelete(type.id),
|
||||
})}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('location.noTypes')}
|
||||
tableName={t('location.manageTypes')}
|
||||
columns={[
|
||||
...locationTypeColumns(t, locale),
|
||||
locationTypeColumnActions(t, {
|
||||
onEdit: handleEdit,
|
||||
onDelete: (type) => handleDelete(type.id),
|
||||
}),
|
||||
]}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
emptyText={t('location.noTypes')}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler, ModalFooter } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -33,6 +33,7 @@ import type { Location } from '../types/location';
|
||||
export function LocationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
||||
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
||||
@@ -82,11 +83,11 @@ export function LocationPage() {
|
||||
closeFormModal();
|
||||
setEditingLocation(null);
|
||||
setParentLocation(null);
|
||||
} catch {
|
||||
notify.error(t('location.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
},
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, t],
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, handleError],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
@@ -96,10 +97,10 @@ export function LocationPage() {
|
||||
notify.success(t('location.deleted'));
|
||||
setSelectedLocation(null);
|
||||
closeDeleteModal();
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
|
||||
|
||||
if (typesLoading) {
|
||||
return (
|
||||
@@ -225,14 +226,14 @@ export function LocationPage() {
|
||||
name: selectedLocation?.names[locale] ?? '',
|
||||
})}
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDeleteModal} size="sm">
|
||||
{t('location.cancel')}
|
||||
</Button>
|
||||
<Button color="red" onClick={handleDeleteConfirm} size="sm">
|
||||
{t('location.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
<LocationTypeModal opened={typeModalOpened} onClose={closeTypeModal} />
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -7,32 +7,29 @@ import {
|
||||
type LicenseStatus,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
export const logisticsHeadDashboardColumns: AdvancedTableColumn<LicenseApplication>[] =
|
||||
export const logisticsHeadDashboardColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
[
|
||||
{
|
||||
key: 'applicationNumber',
|
||||
header: 'Number',
|
||||
render: (app) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{app.applicationNumber}
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'companyName',
|
||||
header: 'Company',
|
||||
render: (app) => <Text size="sm">{app.companyName ?? '—'}</Text>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.companyName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLORS[app.status as LicenseStatus]}
|
||||
color={STATUS_COLORS[row.original.status as LicenseStatus]}
|
||||
>
|
||||
{STATUS_LABELS[app.status as LicenseStatus]}
|
||||
{STATUS_LABELS[row.original.status as LicenseStatus]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
|
||||
@@ -13,7 +13,7 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconChevronRight } from '@tabler/icons-react';
|
||||
import { AdvancedTable } from '@ema-platform/ui';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -34,6 +34,7 @@ export function LogisticsHeadDashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const queue = useGetQueueQuery();
|
||||
const mine = useGetAssignedToMeQuery();
|
||||
const table = useServerTable();
|
||||
|
||||
if (queue.isLoading || mine.isLoading) {
|
||||
return (
|
||||
@@ -73,6 +74,8 @@ export function LogisticsHeadDashboardPage() {
|
||||
)
|
||||
.slice(0, 8);
|
||||
|
||||
const paged = table.paginate(recent);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb="xs">
|
||||
@@ -112,14 +115,18 @@ export function LogisticsHeadDashboardPage() {
|
||||
</Text>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
tableName="Most recent"
|
||||
columns={logisticsHeadDashboardColumns}
|
||||
data={recent}
|
||||
rowKey={(app) => app.id}
|
||||
onRefresh={() => {
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={() => {
|
||||
queue.refetch();
|
||||
mine.refetch();
|
||||
}}
|
||||
emptyTitle="No licence applications yet."
|
||||
emptyText="No licence applications yet."
|
||||
onRowClick={(app) => navigate(`/licence-review/${app.id}`)}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -0,0 +1,99 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconCheck, IconPaperclip, IconX } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
|
||||
export function medicalActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
ruling: boolean;
|
||||
onEvidence: (certificate: MedicalCertificate) => void;
|
||||
onVerify: (certificate: MedicalCertificate) => void;
|
||||
onReject: (certificate: MedicalCertificate) => void;
|
||||
},
|
||||
): AdvancedColumn<MedicalCertificate> {
|
||||
return {
|
||||
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={() => handlers.onEvidence(row.original)}
|
||||
>
|
||||
{t('recordVerification.evidence', 'Evidence')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
export function seaServiceActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
ruling: boolean;
|
||||
onEvidence: (record: SeaServiceRecord) => void;
|
||||
onVerify: (record: SeaServiceRecord) => void;
|
||||
onReject: (record: SeaServiceRecord) => void;
|
||||
},
|
||||
): AdvancedColumn<SeaServiceRecord> {
|
||||
return {
|
||||
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={() => handlers.onEvidence(row.original)}
|
||||
>
|
||||
{t('recordVerification.evidence', 'Evidence')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={handlers.ruling}
|
||||
onClick={() => handlers.onVerify(row.original)}
|
||||
>
|
||||
{t('recordVerification.verify', 'Verify')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={() => handlers.onReject(row.original)}
|
||||
>
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,13 +1,13 @@
|
||||
import { Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconCheck, IconX } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type {
|
||||
MedicalCertificate,
|
||||
SeaServiceRecord,
|
||||
SeafarerProfileSummary,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
export function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
if (!profile) return '—';
|
||||
return (
|
||||
[profile.firstName, profile.middleName, profile.lastName]
|
||||
@@ -16,144 +16,116 @@ function ownerName(profile?: SeafarerProfileSummary): string {
|
||||
);
|
||||
}
|
||||
|
||||
function seafarerCell(profile?: SeafarerProfileSummary) {
|
||||
return (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{ownerName(profile)}
|
||||
</Text>
|
||||
{profile?.seafarerNumber && (
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{profile.seafarerNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
/** Verify/Reject are text buttons by design — kept as a rendered column. */
|
||||
function ruleButtons(
|
||||
ruling: boolean,
|
||||
onVerify: () => void,
|
||||
onReject: () => void,
|
||||
) {
|
||||
return (
|
||||
<Group gap="xs" justify="flex-end" wrap="nowrap">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="teal"
|
||||
leftSection={<IconCheck size={14} />}
|
||||
loading={ruling}
|
||||
onClick={onVerify}
|
||||
>
|
||||
Verify
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
color="red"
|
||||
variant="light"
|
||||
leftSection={<IconX size={14} />}
|
||||
onClick={onReject}
|
||||
>
|
||||
Reject
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
export function medicalColumns(handlers: {
|
||||
ruling: boolean;
|
||||
onVerify: (certificate: MedicalCertificate) => void;
|
||||
onReject: (certificate: MedicalCertificate) => void;
|
||||
}): AdvancedTableColumn<MedicalCertificate>[] {
|
||||
export function medicalColumns(
|
||||
t: TFunction,
|
||||
showDate: (date: string) => string,
|
||||
): AdvancedColumn<MedicalCertificate>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: 'Seafarer',
|
||||
render: (certificate) => seafarerCell(certificate.profile),
|
||||
},
|
||||
{
|
||||
key: 'issuer',
|
||||
header: 'Issuer',
|
||||
render: (certificate) => (
|
||||
<>
|
||||
{certificate.issuerName}
|
||||
{certificate.certificateNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
№ {certificate.certificateNumber}
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'validity',
|
||||
header: 'Validity',
|
||||
render: (certificate) =>
|
||||
`${certificate.issueDate} → ${certificate.expiryDate}`,
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'fitness',
|
||||
header: 'Fitness',
|
||||
render: (certificate) => (
|
||||
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">
|
||||
{certificate.fitnessStatus}
|
||||
{t(`recordVerification.fitness.${row.original.fitnessStatus}`, row.original.fitnessStatus)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (certificate) =>
|
||||
ruleButtons(
|
||||
handlers.ruling,
|
||||
() => handlers.onVerify(certificate),
|
||||
() => handlers.onReject(certificate),
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function seaServiceColumns(handlers: {
|
||||
ruling: boolean;
|
||||
onVerify: (record: SeaServiceRecord) => void;
|
||||
onReject: (record: SeaServiceRecord) => void;
|
||||
}): AdvancedTableColumn<SeaServiceRecord>[] {
|
||||
export function seaServiceColumns(
|
||||
t: TFunction,
|
||||
showDate: (date: string) => string,
|
||||
): AdvancedColumn<SeaServiceRecord>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: 'Seafarer',
|
||||
render: (record) => seafarerCell(record.profile),
|
||||
},
|
||||
{
|
||||
key: 'vessel',
|
||||
header: 'Vessel',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
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>
|
||||
),
|
||||
},
|
||||
{ key: 'rank', header: 'Rank' },
|
||||
{
|
||||
key: 'period',
|
||||
header: 'Period',
|
||||
render: (record) => `${record.engagementDate} → ${record.dischargeDate}`,
|
||||
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>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (record) =>
|
||||
ruleButtons(
|
||||
handlers.ruling,
|
||||
() => handlers.onVerify(record),
|
||||
() => handlers.onReject(record),
|
||||
),
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,27 +1,42 @@
|
||||
import { useState } from 'react';
|
||||
import { useCallback, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Paper,
|
||||
Stack,
|
||||
Tabs,
|
||||
Text,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconStethoscope } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconEye,
|
||||
IconInbox,
|
||||
IconPaperclip,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, type AdvancedColumn } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetAttachmentsQuery,
|
||||
useGetPendingMedicalQuery,
|
||||
useGetPendingSeaServiceQuery,
|
||||
useVerifyMedicalCertificateMutation,
|
||||
useVerifySeaServiceRecordMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
import { medicalColumns, seaServiceColumns } from './columns';
|
||||
import { medicalColumns, seaServiceColumns, ownerName } from './columns';
|
||||
import { medicalActionsColumn, seaServiceActionsColumn } from './actions';
|
||||
|
||||
const PAGE_SIZE = 10;
|
||||
|
||||
/** Reject dialog — the remark is what the seafarer sees and must act on. */
|
||||
function RejectModal({
|
||||
@@ -37,12 +52,13 @@ function RejectModal({
|
||||
onConfirm: (remark: string) => void;
|
||||
loading: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [remark, setRemark] = useState('');
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} centered>
|
||||
<Stack>
|
||||
<Textarea
|
||||
label="What must the seafarer fix?"
|
||||
label={t('recordVerification.rejectReasonLabel', 'What must the seafarer fix?')}
|
||||
required
|
||||
minRows={2}
|
||||
value={remark}
|
||||
@@ -50,7 +66,7 @@ function RejectModal({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
{t('recordVerification.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color="red"
|
||||
@@ -61,7 +77,7 @@ function RejectModal({
|
||||
setRemark('');
|
||||
}}
|
||||
>
|
||||
Reject
|
||||
{t('recordVerification.reject', 'Reject')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -69,6 +85,93 @@ function RejectModal({
|
||||
);
|
||||
}
|
||||
|
||||
/** Attachments / Evidence Modal for viewing uploaded files for a record */
|
||||
function AttachmentsModal({
|
||||
opened,
|
||||
ownerType,
|
||||
ownerId,
|
||||
title,
|
||||
onClose,
|
||||
}: {
|
||||
opened: boolean;
|
||||
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
||||
ownerId: string | null;
|
||||
title: string;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const { data: attachments, isLoading } = useGetAttachmentsQuery(
|
||||
{ ownerType, ownerId: ownerId ?? '' },
|
||||
{ skip: !ownerId },
|
||||
);
|
||||
|
||||
const files = useMemo(
|
||||
() => (attachments ?? []).flatMap((a) => a.files ?? []),
|
||||
[attachments],
|
||||
);
|
||||
|
||||
return (
|
||||
<Modal opened={opened} onClose={onClose} title={title} size="md" centered>
|
||||
<Stack gap="sm">
|
||||
{isLoading ? (
|
||||
<Center py="xl">
|
||||
<Loader size="sm" />
|
||||
</Center>
|
||||
) : files.length === 0 ? (
|
||||
<Center py="xl">
|
||||
<Group gap="xs" c="dimmed">
|
||||
<IconInbox size={18} />
|
||||
<Text size="sm" c="dimmed">
|
||||
{t(
|
||||
'recordVerification.noAttachments',
|
||||
'No evidence attachments uploaded for this record.',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
</Center>
|
||||
) : (
|
||||
files.map((file) => (
|
||||
<Paper key={file.id} withBorder p="xs" radius="sm">
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="xs" wrap="nowrap" style={{ minWidth: 0 }}>
|
||||
<IconPaperclip size={18} />
|
||||
<div style={{ minWidth: 0 }}>
|
||||
<Text size="sm" fw={500} truncate>
|
||||
{file.originalName}
|
||||
</Text>
|
||||
{file.sizeBytes && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{(file.sizeBytes / 1024).toFixed(1)} KB
|
||||
</Text>
|
||||
)}
|
||||
</div>
|
||||
</Group>
|
||||
{file.url ? (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconEye size={14} />}
|
||||
component="a"
|
||||
href={file.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
{t('recordVerification.view', 'View')}
|
||||
</Button>
|
||||
) : (
|
||||
<Badge size="sm" variant="light" color="gray">
|
||||
{t('recordVerification.noUrl', 'No URL')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
</Paper>
|
||||
))
|
||||
)}
|
||||
</Stack>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* The record-verification workspace (US-SSM-003/007): everything seafarers
|
||||
* have submitted and no officer has ruled on yet, oldest first. VERIFIED
|
||||
@@ -76,112 +179,223 @@ function RejectModal({
|
||||
* certificate starts satisfying the submission gate.
|
||||
*/
|
||||
export function MedicalVerificationPage() {
|
||||
const { t } = useTranslation();
|
||||
const {
|
||||
data: pendingMedical,
|
||||
isLoading: loadingMedical,
|
||||
isFetching: fetchingMedical,
|
||||
refetch: refetchMedical,
|
||||
} = useGetPendingMedicalQuery();
|
||||
|
||||
const {
|
||||
data: pendingSeaService,
|
||||
isLoading: loadingSeaService,
|
||||
isFetching: fetchingSeaService,
|
||||
refetch: refetchSeaService,
|
||||
} = useGetPendingSeaServiceQuery();
|
||||
|
||||
const [verifyMedical, { isLoading: rulingMedical }] =
|
||||
useVerifyMedicalCertificateMutation();
|
||||
const [verifySeaService, { isLoading: rulingSeaService }] =
|
||||
useVerifySeaServiceRecordMutation();
|
||||
|
||||
const [rejectMedical, setRejectMedical] = useState<MedicalCertificate | null>(
|
||||
null,
|
||||
);
|
||||
const [rejectSeaService, setRejectSeaService] =
|
||||
useState<SeaServiceRecord | null>(null);
|
||||
|
||||
const rule = async (
|
||||
run: () => Promise<unknown>,
|
||||
done: string,
|
||||
): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not record the ruling'));
|
||||
}
|
||||
};
|
||||
const [attachmentModal, setAttachmentModal] = useState<{
|
||||
ownerType: 'MEDICAL_CERTIFICATE' | 'SEA_SERVICE_RECORD';
|
||||
ownerId: string;
|
||||
title: string;
|
||||
} | null>(null);
|
||||
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const [medicalPage, setMedicalPage] = useState(0);
|
||||
const [seaServicePage, setSeaServicePage] = useState(0);
|
||||
const [medicalPageSize, setMedicalPageSize] = useState(PAGE_SIZE);
|
||||
const [seaServicePageSize, setSeaServicePageSize] = useState(PAGE_SIZE);
|
||||
|
||||
const rule = useCallback(
|
||||
async (run: () => Promise<unknown>, done: string): Promise<void> => {
|
||||
try {
|
||||
await run();
|
||||
notify.success(done);
|
||||
} catch (error) {
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('recordVerification.rulingFailed', 'Could not record the ruling')),
|
||||
);
|
||||
}
|
||||
},
|
||||
[t],
|
||||
);
|
||||
|
||||
const pendingMedicalList = useMemo(
|
||||
() => pendingMedical ?? [],
|
||||
[pendingMedical],
|
||||
);
|
||||
const pendingSeaServiceList = useMemo(
|
||||
() => pendingSeaService ?? [],
|
||||
[pendingSeaService],
|
||||
);
|
||||
|
||||
const pagedMedical = useMemo(() => {
|
||||
const start = medicalPage * medicalPageSize;
|
||||
return pendingMedicalList.slice(start, start + medicalPageSize);
|
||||
}, [pendingMedicalList, medicalPage, medicalPageSize]);
|
||||
|
||||
const pagedSeaService = useMemo(() => {
|
||||
const start = seaServicePage * seaServicePageSize;
|
||||
return pendingSeaServiceList.slice(start, start + seaServicePageSize);
|
||||
}, [pendingSeaServiceList, seaServicePage, seaServicePageSize]);
|
||||
|
||||
const handleMedicalPageSizeChange = useCallback((size: number) => {
|
||||
setMedicalPageSize(size);
|
||||
setMedicalPage(0);
|
||||
}, []);
|
||||
|
||||
const handleSeaServicePageSizeChange = useCallback((size: number) => {
|
||||
setSeaServicePageSize(size);
|
||||
setSeaServicePage(0);
|
||||
}, []);
|
||||
|
||||
const medicalTableColumns: AdvancedColumn<MedicalCertificate>[] = useMemo(
|
||||
() => [
|
||||
...medicalColumns(t, showDate),
|
||||
medicalActionsColumn(t, {
|
||||
ruling: rulingMedical,
|
||||
onEvidence: (certificate) =>
|
||||
setAttachmentModal({
|
||||
ownerType: 'MEDICAL_CERTIFICATE',
|
||||
ownerId: certificate.id,
|
||||
title: t('recordVerification.evidenceTitleMedical', {
|
||||
name: ownerName(certificate.profile),
|
||||
defaultValue: 'Evidence for {{name}} Medical Certificate',
|
||||
}),
|
||||
}),
|
||||
onVerify: (certificate) =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
t('recordVerification.certificateVerified', 'Certificate verified'),
|
||||
),
|
||||
onReject: (certificate) => setRejectMedical(certificate),
|
||||
}),
|
||||
],
|
||||
[rulingMedical, rule, verifyMedical, showDate, t],
|
||||
);
|
||||
|
||||
const seaServiceTableColumns: AdvancedColumn<SeaServiceRecord>[] = useMemo(
|
||||
() => [
|
||||
...seaServiceColumns(t, showDate),
|
||||
seaServiceActionsColumn(t, {
|
||||
ruling: rulingSeaService,
|
||||
onEvidence: (record) =>
|
||||
setAttachmentModal({
|
||||
ownerType: 'SEA_SERVICE_RECORD',
|
||||
ownerId: record.id,
|
||||
title: t('recordVerification.evidenceTitleSeaService', {
|
||||
name: ownerName(record.profile),
|
||||
defaultValue: 'Evidence for {{name}} Sea Service Record',
|
||||
}),
|
||||
}),
|
||||
onVerify: (record) =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
|
||||
),
|
||||
onReject: (record) => setRejectSeaService(record),
|
||||
}),
|
||||
],
|
||||
[rulingSeaService, rule, verifySeaService, showDate, t],
|
||||
);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Title order={3} mb={4}>
|
||||
Record verification
|
||||
{t('recordVerification.title', 'Record verification')}
|
||||
</Title>
|
||||
<Text size="sm" c="dimmed" mb="md">
|
||||
Submitted sea-service records and medical certificates awaiting a
|
||||
ruling. Verified records are frozen; rejections return to the seafarer
|
||||
with your remark.
|
||||
{t(
|
||||
'recordVerification.subtitle',
|
||||
'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
)}
|
||||
</Text>
|
||||
|
||||
<Tabs defaultValue="medical" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
|
||||
Medical ({pendingMedical?.length ?? 0})
|
||||
{t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
|
||||
Sea Service ({pendingSeaService?.length ?? 0})
|
||||
{t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="medical" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={medicalColumns({
|
||||
ruling: rulingMedical,
|
||||
onVerify: (certificate) =>
|
||||
rule(
|
||||
() =>
|
||||
verifyMedical({
|
||||
id: certificate.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Certificate verified',
|
||||
),
|
||||
onReject: setRejectMedical,
|
||||
})}
|
||||
data={pendingMedical ?? []}
|
||||
rowKey={(certificate) => certificate.id}
|
||||
loading={loadingMedical}
|
||||
onRefresh={refetchMedical}
|
||||
emptyTitle="Nothing awaiting verification."
|
||||
/>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
columns={medicalTableColumns}
|
||||
data={pagedMedical}
|
||||
tableName={t('recordVerification.tabs.medical', {
|
||||
count: pendingMedicalList.length,
|
||||
defaultValue: 'Medical ({{count}})',
|
||||
})}
|
||||
itemCount={pendingMedicalList.length}
|
||||
pageIndex={medicalPage}
|
||||
onPageChange={setMedicalPage}
|
||||
pageSize={medicalPageSize}
|
||||
onPageSizeChange={handleMedicalPageSizeChange}
|
||||
refresh={refetchMedical}
|
||||
isLoading={loadingMedical || fetchingMedical}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="md">
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns({
|
||||
ruling: rulingSeaService,
|
||||
onVerify: (record) =>
|
||||
rule(
|
||||
() =>
|
||||
verifySeaService({
|
||||
id: record.id,
|
||||
outcome: 'VERIFIED',
|
||||
}).unwrap(),
|
||||
'Sea-service record verified',
|
||||
),
|
||||
onReject: setRejectSeaService,
|
||||
})}
|
||||
data={pendingSeaService ?? []}
|
||||
rowKey={(record) => record.id}
|
||||
loading={loadingSeaService}
|
||||
onRefresh={refetchSeaService}
|
||||
emptyTitle="Nothing awaiting verification."
|
||||
/>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
columns={seaServiceTableColumns}
|
||||
data={pagedSeaService}
|
||||
tableName={t('recordVerification.tabs.seaService', {
|
||||
count: pendingSeaServiceList.length,
|
||||
defaultValue: 'Sea Service ({{count}})',
|
||||
})}
|
||||
itemCount={pendingSeaServiceList.length}
|
||||
pageIndex={seaServicePage}
|
||||
onPageChange={setSeaServicePage}
|
||||
pageSize={seaServicePageSize}
|
||||
onPageSizeChange={handleSeaServicePageSizeChange}
|
||||
refresh={refetchSeaService}
|
||||
isLoading={loadingSeaService || fetchingSeaService}
|
||||
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')}
|
||||
/>
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
|
||||
<AttachmentsModal
|
||||
opened={Boolean(attachmentModal)}
|
||||
ownerType={attachmentModal?.ownerType ?? 'MEDICAL_CERTIFICATE'}
|
||||
ownerId={attachmentModal?.ownerId ?? null}
|
||||
title={attachmentModal?.title ?? t('recordVerification.evidenceModalTitle', 'Evidence Attachments')}
|
||||
onClose={() => setAttachmentModal(null)}
|
||||
/>
|
||||
|
||||
<RejectModal
|
||||
title="Reject medical certificate"
|
||||
title={t('recordVerification.rejectMedicalTitle', 'Reject medical certificate')}
|
||||
opened={Boolean(rejectMedical)}
|
||||
onClose={() => setRejectMedical(null)}
|
||||
loading={rulingMedical}
|
||||
@@ -194,13 +408,13 @@ export function MedicalVerificationPage() {
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Certificate rejected',
|
||||
t('recordVerification.certificateRejected', 'Certificate rejected'),
|
||||
);
|
||||
setRejectMedical(null);
|
||||
}}
|
||||
/>
|
||||
<RejectModal
|
||||
title="Reject sea-service record"
|
||||
title={t('recordVerification.rejectSeaServiceTitle', 'Reject sea-service record')}
|
||||
opened={Boolean(rejectSeaService)}
|
||||
onClose={() => setRejectSeaService(null)}
|
||||
loading={rulingSeaService}
|
||||
@@ -213,7 +427,7 @@ export function MedicalVerificationPage() {
|
||||
outcome: 'REJECTED',
|
||||
remark,
|
||||
}).unwrap(),
|
||||
'Sea-service record rejected',
|
||||
t('recordVerification.seaServiceRejected', 'Sea-service record rejected'),
|
||||
);
|
||||
setRejectSeaService(null);
|
||||
}}
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Button } from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
export function paymentConfigActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: { onEdit: (type: LicenseType) => void },
|
||||
): AdvancedColumn<LicenseType> {
|
||||
return {
|
||||
header: '',
|
||||
size: 90,
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(row.original)}
|
||||
>
|
||||
{t('paymentConfig.edit', 'Edit')}
|
||||
</Button>
|
||||
),
|
||||
};
|
||||
}
|
||||
@@ -1,8 +1,7 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconEdit } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import { localized } from '@ema-platform/api';
|
||||
import { Badge, Text, Tooltip } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
function feeText(amount: string | number | null, currency: string): string {
|
||||
if (amount === null || amount === '') return '—';
|
||||
@@ -11,89 +10,87 @@ function feeText(amount: string | number | null, currency: string): string {
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
/** Edit stays a text button by design — kept as a rendered column. */
|
||||
export function paymentConfigColumns(handlers: {
|
||||
onEdit: (type: LicenseType) => void;
|
||||
}): AdvancedTableColumn<LicenseType>[] {
|
||||
export function paymentConfigColumns(
|
||||
t: TFunction,
|
||||
localized: (value: LicenseType['name']) => string,
|
||||
): AdvancedColumn<LicenseType>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Licence type',
|
||||
render: (type) => (
|
||||
header: t('paymentConfig.columns.type', 'Licence type'),
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(type.name)}
|
||||
{localized(row.original.name)}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" ff="monospace">
|
||||
{type.key}
|
||||
{row.original.key}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeNewApplication',
|
||||
header: 'New application',
|
||||
render: (type) => (
|
||||
header: t('paymentConfig.columns.newApplication', 'New application'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}
|
||||
{feeText(row.original.feeNewApplication, row.original.feeCurrency)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'feeRenewal',
|
||||
header: 'Renewal',
|
||||
render: (type) =>
|
||||
type.feeNewApplication === null ? (
|
||||
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.
|
||||
<Text size="sm" c="dimmed">
|
||||
—
|
||||
</Text>
|
||||
) : type.feeRenewal === null ? (
|
||||
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
|
||||
return (
|
||||
<Text size="sm" c="dimmed">
|
||||
{feeText(type.feeNewApplication, type.feeCurrency)}{' '}
|
||||
<Text span size="xs" c="dimmed">
|
||||
(same as new)
|
||||
</Text>
|
||||
—
|
||||
</Text>
|
||||
</Tooltip>
|
||||
) : (
|
||||
);
|
||||
}
|
||||
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>
|
||||
),
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'charged',
|
||||
header: 'Charged?',
|
||||
render: (type) =>
|
||||
type.issuesCertificate ? (
|
||||
header: t('paymentConfig.columns.charged', 'Charged?'),
|
||||
cell: ({ row }) =>
|
||||
row.original.issuesCertificate ? (
|
||||
<Badge variant="light" color="teal" size="sm">
|
||||
On approval
|
||||
{t('paymentConfig.chargedOnApproval', 'On approval')}
|
||||
</Badge>
|
||||
) : (
|
||||
<Tooltip label="This licence type ends with an EMA decision and never reaches a payment stage">
|
||||
<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">
|
||||
Not charged
|
||||
{t('paymentConfig.chargedNotCharged', 'Not charged')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'edit',
|
||||
header: '',
|
||||
width: 90,
|
||||
align: 'right',
|
||||
render: (type) => (
|
||||
<Button
|
||||
size="xs"
|
||||
variant="light"
|
||||
leftSection={<IconEdit size={14} />}
|
||||
onClick={() => handlers.onEdit(type)}
|
||||
>
|
||||
Edit
|
||||
</Button>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Group,
|
||||
Loader,
|
||||
@@ -24,16 +24,23 @@ import {
|
||||
IconInfoCircle,
|
||||
IconLock,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
notify,
|
||||
ModalFooter,
|
||||
AdvancedTable,
|
||||
useServerTable,
|
||||
type AdvancedColumn,
|
||||
} from '@ema-platform/ui';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetPaymentCapabilitiesQuery,
|
||||
useUpdateLicenseFeesMutation,
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseType } from '@ema-platform/api';
|
||||
import { paymentConfigColumns } from './columns';
|
||||
import { paymentConfigActionsColumn } from './actions';
|
||||
|
||||
/**
|
||||
* Licence fee configuration.
|
||||
@@ -47,9 +54,13 @@ import { paymentConfigColumns } from './columns';
|
||||
*/
|
||||
|
||||
export function PaymentConfigPage() {
|
||||
const { data, isLoading, error, refetch } = useGetLicenseTypesQuery();
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const { data, isLoading, isFetching, error, refetch } =
|
||||
useGetLicenseTypesQuery();
|
||||
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
|
||||
const [editing, setEditing] = useState<LicenseType | null>(null);
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
@@ -64,7 +75,7 @@ export function PaymentConfigPage() {
|
||||
<Alert
|
||||
color="red"
|
||||
icon={<IconAlertTriangle size={18} />}
|
||||
title="Could not load licence types"
|
||||
title={t('paymentConfig.loadError', 'Could not load licence types')}
|
||||
>
|
||||
<Text size="sm">{extractErrorMessage(error)}</Text>
|
||||
</Alert>
|
||||
@@ -74,15 +85,23 @@ export function PaymentConfigPage() {
|
||||
const types = [...(data?.items ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
const page = paginate(types);
|
||||
|
||||
const columns: AdvancedColumn<LicenseType>[] = [
|
||||
...paymentConfigColumns(t, localized),
|
||||
paymentConfigActionsColumn(t, { onEdit: setEditing }),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Title order={3}>Payment configuration</Title>
|
||||
<Title order={3}>{t('paymentConfig.title', 'Payment configuration')}</Title>
|
||||
<Text size="sm" c="dimmed" mt={4}>
|
||||
What each licence costs. Applicants are charged after approval, and
|
||||
the amount is fixed onto the application at that moment.
|
||||
{t(
|
||||
'paymentConfig.subtitle',
|
||||
'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
)}
|
||||
</Text>
|
||||
</div>
|
||||
<ThemeIcon size="xl" radius="md" variant="light">
|
||||
@@ -97,22 +116,25 @@ export function PaymentConfigPage() {
|
||||
icon={<IconInfoCircle size={18} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
A change applies to applications approved from now on. Anything
|
||||
already approved keeps the amount it was quoted, so an edit here can
|
||||
never alter what an applicant has already been asked to pay.
|
||||
{t(
|
||||
'paymentConfig.changeNotice',
|
||||
'A change applies to applications approved from now on. Anything already approved keeps the amount it was quoted, so an edit here can never alter what an applicant has already been asked to pay.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<AdvancedTable
|
||||
columns={paymentConfigColumns({ onEdit: setEditing })}
|
||||
data={types}
|
||||
rowKey={(type) => type.id}
|
||||
onRefresh={refetch}
|
||||
minWidth={820}
|
||||
verticalSpacing="sm"
|
||||
/>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
tableName="payment-config-license-types"
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
/>
|
||||
|
||||
<GatewayPanel bypassEnabled={capabilities?.bypassEnabled} />
|
||||
|
||||
@@ -127,6 +149,7 @@ export function PaymentConfigPage() {
|
||||
* them as editable fields would be a lie.
|
||||
*/
|
||||
function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
const { t } = useTranslation();
|
||||
return (
|
||||
<Paper withBorder radius="md" p="md">
|
||||
<Group gap="xs" mb="xs">
|
||||
@@ -134,23 +157,31 @@ function GatewayPanel({ bypassEnabled }: { bypassEnabled?: boolean }) {
|
||||
<IconLock size={13} />
|
||||
</ThemeIcon>
|
||||
<Text fw={600} size="sm">
|
||||
Payment gateway
|
||||
{t('paymentConfig.gateway.title', 'Payment gateway')}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
Set by the payment service environment — not editable here.
|
||||
{t(
|
||||
'paymentConfig.gateway.subtitle',
|
||||
'Set by the payment service environment — not editable here.',
|
||||
)}
|
||||
</Text>
|
||||
</Group>
|
||||
<Group gap="sm">
|
||||
<Badge variant="light">Telebirr</Badge>
|
||||
<Badge variant="light">{t('paymentConfig.gateway.provider', 'Telebirr')}</Badge>
|
||||
{bypassEnabled ? (
|
||||
<Tooltip label="ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.">
|
||||
<Tooltip
|
||||
label={t(
|
||||
'paymentConfig.gateway.bypassTooltip',
|
||||
'ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.',
|
||||
)}
|
||||
>
|
||||
<Badge variant="light" color="orange">
|
||||
Test bypass enabled
|
||||
{t('paymentConfig.gateway.bypassEnabled', 'Test bypass enabled')}
|
||||
</Badge>
|
||||
</Tooltip>
|
||||
) : (
|
||||
<Badge variant="light" color="gray">
|
||||
Test bypass off
|
||||
{t('paymentConfig.gateway.bypassOff', 'Test bypass off')}
|
||||
</Badge>
|
||||
)}
|
||||
</Group>
|
||||
@@ -165,6 +196,8 @@ function FeeEditModal({
|
||||
licenseType: LicenseType | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const [updateFees, { isLoading }] = useUpdateLicenseFeesMutation();
|
||||
const [newFee, setNewFee] = useState<number | ''>('');
|
||||
const [renewalFee, setRenewalFee] = useState<number | ''>('');
|
||||
@@ -192,11 +225,21 @@ function FeeEditModal({
|
||||
async function save() {
|
||||
if (!licenseType) return;
|
||||
if (chargeable && newFee === '') {
|
||||
notify.error('Enter a new-application fee, or turn off "carries a fee".');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingNewFee',
|
||||
'Enter a new-application fee, or turn off "carries a fee".',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (chargeable && !sameAsNew && renewalFee === '') {
|
||||
notify.error('Enter a renewal fee, or charge renewal at the same rate.');
|
||||
notify.error(
|
||||
t(
|
||||
'paymentConfig.modal.missingRenewalFee',
|
||||
'Enter a renewal fee, or charge renewal at the same rate.',
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
@@ -206,10 +249,17 @@ function FeeEditModal({
|
||||
feeRenewal: chargeable && !sameAsNew ? Number(renewalFee) : null,
|
||||
feeCurrency: currency.trim() || 'ETB',
|
||||
}).unwrap();
|
||||
notify.success(`${localized(licenseType.name)} fees updated.`);
|
||||
notify.success(
|
||||
t('paymentConfig.modal.updated', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: '{{type}} fees updated.',
|
||||
}),
|
||||
);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err, 'Could not save the fees'));
|
||||
notify.error(
|
||||
extractErrorMessage(err, t('paymentConfig.modal.saveFailed', 'Could not save the fees')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -217,7 +267,14 @@ function FeeEditModal({
|
||||
<Modal
|
||||
opened={!!licenseType}
|
||||
onClose={onClose}
|
||||
title={licenseType ? `Fees — ${localized(licenseType.name)}` : ''}
|
||||
title={
|
||||
licenseType
|
||||
? t('paymentConfig.modal.title', {
|
||||
type: localized(licenseType.name),
|
||||
defaultValue: 'Fees — {{type}}',
|
||||
})
|
||||
: ''
|
||||
}
|
||||
centered
|
||||
radius="lg"
|
||||
>
|
||||
@@ -230,9 +287,10 @@ function FeeEditModal({
|
||||
icon={<IconInfoCircle size={16} />}
|
||||
>
|
||||
<Text size="sm">
|
||||
This licence type concludes with an EMA decision and never
|
||||
reaches a payment stage, so a fee set here stays unused until
|
||||
that changes.
|
||||
{t(
|
||||
'paymentConfig.modal.noPaymentStage',
|
||||
'This licence type concludes with an EMA decision and never reaches a payment stage, so a fee set here stays unused until that changes.',
|
||||
)}
|
||||
</Text>
|
||||
</Alert>
|
||||
)}
|
||||
@@ -240,14 +298,17 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={chargeable}
|
||||
onChange={(e) => setChargeable(e.currentTarget.checked)}
|
||||
label="This licence carries a fee"
|
||||
description="Turn off for licence types applicants are never charged for."
|
||||
label={t('paymentConfig.modal.chargeableLabel', 'This licence carries a fee')}
|
||||
description={t(
|
||||
'paymentConfig.modal.chargeableDescription',
|
||||
'Turn off for licence types applicants are never charged for.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{chargeable && (
|
||||
<>
|
||||
<NumberInput
|
||||
label="New application fee"
|
||||
label={t('paymentConfig.modal.newFeeLabel', 'New application fee')}
|
||||
value={newFee}
|
||||
onChange={(v) => setNewFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -260,13 +321,16 @@ function FeeEditModal({
|
||||
<Switch
|
||||
checked={sameAsNew}
|
||||
onChange={(e) => setSameAsNew(e.currentTarget.checked)}
|
||||
label="Charge renewal at the same rate"
|
||||
description="Turn off to set a separate renewal fee."
|
||||
label={t('paymentConfig.modal.sameRateLabel', 'Charge renewal at the same rate')}
|
||||
description={t(
|
||||
'paymentConfig.modal.sameRateDescription',
|
||||
'Turn off to set a separate renewal fee.',
|
||||
)}
|
||||
/>
|
||||
|
||||
{!sameAsNew && (
|
||||
<NumberInput
|
||||
label="Renewal fee"
|
||||
label={t('paymentConfig.modal.renewalFeeLabel', 'Renewal fee')}
|
||||
value={renewalFee}
|
||||
onChange={(v) => setRenewalFee(v === '' ? '' : Number(v))}
|
||||
min={0}
|
||||
@@ -278,7 +342,7 @@ function FeeEditModal({
|
||||
)}
|
||||
|
||||
<TextInput
|
||||
label="Currency"
|
||||
label={t('paymentConfig.modal.currencyLabel', 'Currency')}
|
||||
value={currency}
|
||||
onChange={(e) =>
|
||||
setCurrency(e.currentTarget.value.toUpperCase())
|
||||
@@ -288,14 +352,14 @@ function FeeEditModal({
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end" mt="xs">
|
||||
<ModalFooter mt="xs">
|
||||
<Button variant="default" onClick={onClose} disabled={isLoading}>
|
||||
Cancel
|
||||
{t('paymentConfig.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button onClick={save} loading={isLoading}>
|
||||
Save fees
|
||||
{t('paymentConfig.modal.save', 'Save fees')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
)}
|
||||
</Modal>
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -77,6 +77,7 @@ export function ProfilePage() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -156,8 +157,8 @@ export function ProfilePage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@@ -169,12 +170,10 @@ export function ProfilePage() {
|
||||
oldPassword: z
|
||||
.string()
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
newPassword: strongPasswordSchema(12),
|
||||
confirmPassword: z
|
||||
.string()
|
||||
.min(8, { message: t('profile.validation.passwordMin') }),
|
||||
.min(1, { message: t('profile.validation.passwordMin') }),
|
||||
})
|
||||
.refine((data) => data.newPassword === data.confirmPassword, {
|
||||
message: t('profile.validation.passwordMismatch'),
|
||||
@@ -208,8 +207,8 @@ export function ProfilePage() {
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
@@ -413,12 +412,15 @@ export function ProfilePage() {
|
||||
{...registerPassword('oldPassword')}
|
||||
/>
|
||||
<SimpleGrid cols={{ base: 1, sm: 2 }} spacing="md">
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.newPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
error={passwordErrors.newPassword?.message}
|
||||
{...registerPassword('newPassword')}
|
||||
/>
|
||||
<PasswordRequirements password={watchPassword('newPassword')} minLength={12} />
|
||||
</div>
|
||||
<PasswordInput
|
||||
label={t('profile.fields.confirmPassword')}
|
||||
leftSection={<IconLock size={18} />}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
import { ActionIcon, Button, Group } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Question } from '../../types/question';
|
||||
|
||||
export function questionActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
isSubmittingReview: boolean;
|
||||
onSubmitForApproval: (question: Question) => void;
|
||||
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
|
||||
onEdit: (question: Question) => void;
|
||||
onDelete: (question: Question) => void;
|
||||
},
|
||||
): AdvancedColumn<Question> {
|
||||
return {
|
||||
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={handlers.isSubmittingReview}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button size="compact-xs" variant="light" color="teal" onClick={() => handlers.onReview(q, 'APPROVED')}>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="red" onClick={() => handlers.onReview(q, 'REJECTED')}>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { ActionIcon, Badge, Button, Group, Text } from '@mantine/core';
|
||||
import { IconEdit, IconGavel, IconSend, IconTrash } from '@tabler/icons-react';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Question, QuestionStatus } from '../../types/question';
|
||||
|
||||
/** Bank items travel DRAFT → PENDING_APPROVAL → APPROVED (US-EXAM-003). */
|
||||
@@ -13,103 +12,40 @@ const QC_COLOR: Record<QuestionStatus, string> = {
|
||||
RETIRED: 'dark',
|
||||
};
|
||||
|
||||
/** QC actions are text buttons by design — kept as a rendered column. */
|
||||
export function questionColumns(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
opts: {
|
||||
locale: 'en' | 'am';
|
||||
getCertName: (id: string) => string;
|
||||
isSubmittingReview: boolean;
|
||||
onSubmitForApproval: (question: Question) => void;
|
||||
onReview: (question: Question, outcome: 'APPROVED' | 'REJECTED' | 'RETIRED') => void;
|
||||
onEdit: (question: Question) => void;
|
||||
onDelete: (question: Question) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Question>[] {
|
||||
): AdvancedColumn<Question>[] {
|
||||
return [
|
||||
{
|
||||
key: 'title',
|
||||
header: t('question.columns.title'),
|
||||
render: (q) => <Text fz="sm" maw={300} lineClamp={2}>{q.title[handlers.locale]}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm" maw={300} lineClamp={2}>{row.original.title[opts.locale]}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'certification',
|
||||
header: t('question.columns.certification'),
|
||||
render: (q) => <Text fz="sm">{handlers.getCertName(q.certificationId)}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm">{opts.getCertName(row.original.certificationId)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'form',
|
||||
header: t('question.columns.form'),
|
||||
render: (q) => <Badge size="sm" variant="light" color={q.form === 'ESSAY' ? 'blue' : 'violet'}>{t(`question.form.${q.form === 'ESSAY' ? 'essay' : 'choice'}`)}</Badge>,
|
||||
},
|
||||
{
|
||||
key: 'points',
|
||||
header: t('question.columns.points'),
|
||||
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('question.qc.column'),
|
||||
render: (q) => (
|
||||
<Badge size="sm" variant="light" color={QC_COLOR[q.status] ?? 'gray'} title={q.reviewRemark ?? undefined}>
|
||||
{t(`question.qc.${q.status}`)}
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={row.original.form === 'ESSAY' ? 'blue' : 'violet'}>
|
||||
{t(`question.form.${row.original.form === 'ESSAY' ? 'essay' : 'choice'}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (q) => (
|
||||
<Group gap="xs">
|
||||
{(q.status === 'DRAFT' || q.status === 'REJECTED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconSend size={12} />}
|
||||
loading={handlers.isSubmittingReview}
|
||||
onClick={() => handlers.onSubmitForApproval(q)}
|
||||
>
|
||||
{t('question.qc.submit')}
|
||||
</Button>
|
||||
)}
|
||||
{q.status === 'PENDING_APPROVAL' && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="teal"
|
||||
onClick={() => handlers.onReview(q, 'APPROVED')}
|
||||
>
|
||||
{t('question.qc.approve')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="red"
|
||||
onClick={() => handlers.onReview(q, 'REJECTED')}
|
||||
>
|
||||
{t('question.qc.reject')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{q.status === 'APPROVED' && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="dark"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onReview(q, 'RETIRED')}
|
||||
>
|
||||
{t('question.qc.retire')}
|
||||
</Button>
|
||||
)}
|
||||
<ActionIcon variant="subtle" color="blue" size="sm" onClick={() => handlers.onEdit(q)}>
|
||||
<IconEdit size={14} />
|
||||
</ActionIcon>
|
||||
<ActionIcon variant="subtle" color="red" size="sm" onClick={() => handlers.onDelete(q)}>
|
||||
<IconTrash size={14} />
|
||||
</ActionIcon>
|
||||
</Group>
|
||||
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>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -8,9 +8,7 @@ import {
|
||||
Modal,
|
||||
Text,
|
||||
TextInput,
|
||||
Paper,
|
||||
Loader,
|
||||
Center,
|
||||
Card,
|
||||
Alert,
|
||||
Select,
|
||||
NumberInput,
|
||||
@@ -19,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedColumn, AdvancedTable, ModalFooter, notify, useErrorHandler, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { useGetCertificationsQuery } from '../../../certification/api/certification-api';
|
||||
import {
|
||||
@@ -32,6 +30,7 @@ import {
|
||||
} from '../../api/question-api';
|
||||
import type { Question, QuestionForm } from '../../types/question';
|
||||
import { questionColumns } from './columns';
|
||||
import { questionActionsColumn } from './actions';
|
||||
|
||||
function QuestionForm({
|
||||
editing,
|
||||
@@ -78,7 +77,7 @@ function QuestionForm({
|
||||
};
|
||||
|
||||
return (
|
||||
<Paper p="md" withBorder mb="md" radius="md">
|
||||
<Modal opened onClose={onCancel} title={editing ? t('question.update') : t('question.addQuestion')} size="lg">
|
||||
<form onSubmit={handleSubmit}>
|
||||
<Stack gap="sm">
|
||||
<Select label={t('question.form.certification')} placeholder={t('question.form.selectCertification')} data={certOptions} value={certificationId} onChange={setCertificationId} size="sm" searchable required />
|
||||
@@ -92,21 +91,23 @@ function QuestionForm({
|
||||
<NumberInput label={t('question.form.hours')} value={hours} onChange={(v) => setHours(Number(v))} min={0} size="sm" />
|
||||
<NumberInput label={t('question.form.minutes')} value={minutes} onChange={(v) => setMinutes(Number(v))} min={0} size="sm" />
|
||||
</Group>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onCancel} size="sm">{t('question.cancel')}</Button>
|
||||
<Button type="submit" size="sm" loading={isSubmitting}>{editing ? t('question.update') : t('question.create')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</form>
|
||||
</Paper>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetQuestionsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetQuestionsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
const [updateQ, { isLoading: isUpdating }] = useUpdateQuestionMutation();
|
||||
const [deleteQ] = useDeleteQuestionMutation();
|
||||
@@ -128,6 +129,7 @@ export function QuestionPage() {
|
||||
const certOptions = certifications.filter((c) => c.isActive).map((c) => ({ value: c.id, label: c.name[locale] }));
|
||||
|
||||
const filtered = questions.filter((q) => !certFilter || q.certificationId === certFilter);
|
||||
const page = paginate(filtered);
|
||||
|
||||
const getCertName = (id: string) => certifications.find((c) => c.id === id)?.name?.[locale] ?? '-';
|
||||
|
||||
@@ -194,14 +196,24 @@ export function QuestionPage() {
|
||||
notify.success(t('question.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('question.loadError')} />;
|
||||
|
||||
const columns: AdvancedColumn<Question>[] = [
|
||||
...questionColumns(t, { locale, getCertName }),
|
||||
questionActionsColumn(t, {
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
}),
|
||||
];
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -223,27 +235,33 @@ export function QuestionPage() {
|
||||
/>
|
||||
)}
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('question.pool')}</Text>
|
||||
<Select placeholder={t('question.filterByCertification')} data={[{ value: '', label: 'All' }, ...certOptions]} value={certFilter} onChange={(v) => setCertFilter(v ?? null)} size="sm" style={{ width: 280 }} clearable />
|
||||
<Select
|
||||
placeholder={t('question.filterByCertification')}
|
||||
data={[{ value: '', label: 'All' }, ...certOptions]}
|
||||
value={certFilter}
|
||||
onChange={(v) => { setCertFilter(v ?? null); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
/>
|
||||
</Group>
|
||||
<AdvancedTable
|
||||
columns={questionColumns(t, {
|
||||
locale,
|
||||
getCertName,
|
||||
isSubmittingReview,
|
||||
onSubmitForApproval: handleSubmitForApproval,
|
||||
onReview: openReview,
|
||||
onEdit: (q) => { setEditing(q); setShowForm(true); },
|
||||
onDelete: (q) => { setDeleteTarget(q); openDelete(); },
|
||||
})}
|
||||
data={filtered}
|
||||
rowKey={(q) => q.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('question.noQuestions')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('question.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('question.noQuestions')}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={Boolean(reviewTarget)}
|
||||
@@ -279,10 +297,10 @@ export function QuestionPage() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('question.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('question.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('question.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('question.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
</Stack>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { NumberInput, Text, TextInput } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { QuestionBrief } from '../../../exam/types/exam';
|
||||
|
||||
export function recordResultColumns(
|
||||
@@ -12,40 +12,44 @@ export function recordResultColumns(
|
||||
onScoreChange: (questionId: string, value: number) => void;
|
||||
onRemarkChange: (questionId: string, value: string) => void;
|
||||
},
|
||||
): AdvancedTableColumn<QuestionBrief>[] {
|
||||
): AdvancedColumn<QuestionBrief>[] {
|
||||
return [
|
||||
{
|
||||
key: 'question',
|
||||
header: t('result.recordModal.question'),
|
||||
render: (q) => <Text fz="sm" maw={250} lineClamp={2}>{q.title[locale]}</Text>,
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" maw={250} lineClamp={2}>
|
||||
{row.original.title[locale]}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'maxPoints',
|
||||
header: t('result.recordModal.maxPoints'),
|
||||
render: (q) => <Text fz="sm" fw={600}>{q.points}</Text>,
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" fw={600}>
|
||||
{row.original.points}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: t('result.recordModal.score'),
|
||||
render: (q) => (
|
||||
cell: ({ row }) => (
|
||||
<NumberInput
|
||||
value={handlers.scores[q.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(q.id, Number(v))}
|
||||
value={handlers.scores[row.original.id] ?? 0}
|
||||
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
|
||||
min={0}
|
||||
max={q.points}
|
||||
max={row.original.points}
|
||||
size="xs"
|
||||
style={{ width: 80 }}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('result.recordModal.remark'),
|
||||
render: (q) => (
|
||||
cell: ({ row }) => (
|
||||
<TextInput
|
||||
placeholder={t('result.recordModal.remarkOptional')}
|
||||
value={handlers.questionRemarks[q.id] ?? ''}
|
||||
onChange={(e) => handlers.onRemarkChange(q.id, e.currentTarget.value)}
|
||||
value={handlers.questionRemarks[row.original.id] ?? ''}
|
||||
onChange={(e) => handlers.onRemarkChange(row.original.id, e.currentTarget.value)}
|
||||
size="xs"
|
||||
style={{ minWidth: 160 }}
|
||||
/>
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, ModalFooter, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { recordResultColumns } from './columns';
|
||||
import { useCreateResultMutation } from '../../api/result-api';
|
||||
@@ -55,8 +55,10 @@ export function RecordResultModal({
|
||||
skip: !opened,
|
||||
});
|
||||
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
|
||||
const table = useServerTable();
|
||||
|
||||
const questions = exam.questions ?? [];
|
||||
const pagedQuestions = table.paginate(questions);
|
||||
|
||||
const seafarerOptions = (registrations ?? [])
|
||||
.filter((registration) =>
|
||||
@@ -166,14 +168,18 @@ export function RecordResultModal({
|
||||
<>
|
||||
<Divider label={t('result.recordModal.scorePerQuestion')} labelPosition="center" />
|
||||
<AdvancedTable
|
||||
tableName={t('result.recordModal.title')}
|
||||
columns={recordResultColumns(t, locale, {
|
||||
scores,
|
||||
questionRemarks,
|
||||
onScoreChange: handleScoreChange,
|
||||
onRemarkChange: handleQuestionRemarkChange,
|
||||
})}
|
||||
data={questions}
|
||||
rowKey={(q) => q.id}
|
||||
data={pagedQuestions.rows}
|
||||
itemCount={pagedQuestions.itemCount}
|
||||
pageIndex={pagedQuestions.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
/>
|
||||
|
||||
<Paper withBorder p="sm" radius="md" bg="gray.0">
|
||||
@@ -199,12 +205,12 @@ export function RecordResultModal({
|
||||
size="sm"
|
||||
/>
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={onClose} size="sm">{t('result.cancel')}</Button>
|
||||
<Button onClick={handleSave} size="sm" loading={isSaving}>
|
||||
{t('result.saveResult')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</>
|
||||
)}
|
||||
|
||||
|
||||
@@ -1,73 +1,70 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconGavel } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { ExamAppeal } from '../../types/result';
|
||||
|
||||
export function examAppealsColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
showDate: (date: string | null | undefined) => string,
|
||||
handlers: { onDecide: (appeal: ExamAppeal) => void },
|
||||
): AdvancedTableColumn<ExamAppeal>[] {
|
||||
): AdvancedColumn<ExamAppeal>[] {
|
||||
return [
|
||||
{
|
||||
key: 'number',
|
||||
header: t('result.appeals.number'),
|
||||
render: (appeal) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" ff="monospace" fw={600}>
|
||||
{appeal.appealNumber}
|
||||
{row.original.appealNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'candidate',
|
||||
header: t('result.appeals.candidate'),
|
||||
render: (appeal) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm">
|
||||
{appeal.profile
|
||||
? `${appeal.profile.firstName} ${appeal.profile.lastName}`
|
||||
: appeal.profileId.slice(0, 8)}
|
||||
{row.original.profile
|
||||
? `${row.original.profile.firstName} ${row.original.profile.lastName}`
|
||||
: row.original.profileId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exam',
|
||||
header: t('result.appeals.exam'),
|
||||
render: (appeal) => (
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text fz="sm">
|
||||
{appeal.result?.exam?.title?.[locale] ?? '—'}
|
||||
{row.original.result?.exam?.title?.[locale] ?? '—'}
|
||||
</Text>
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
{appeal.result?.status} · {appeal.result?.totalScore}
|
||||
{row.original.result?.status} · {row.original.result?.totalScore}
|
||||
</Badge>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'reason',
|
||||
header: t('result.appeals.reason'),
|
||||
render: (appeal) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="xs" maw={300} lineClamp={3}>
|
||||
{appeal.reason}
|
||||
{row.original.reason}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'lodged',
|
||||
header: t('result.appeals.lodged'),
|
||||
render: (appeal) => <Text fz="xs">{appeal.createdAt?.slice(0, 10)}</Text>,
|
||||
cell: ({ row }) => <Text fz="xs">{showDate(row.original.createdAt)}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (appeal) => (
|
||||
label: t('result.appeals.decide'),
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={12} />}
|
||||
onClick={() => handlers.onDecide(appeal)}
|
||||
onClick={() => handlers.onDecide(row.original)}
|
||||
>
|
||||
{t('result.appeals.decide')}
|
||||
</Button>
|
||||
|
||||
@@ -15,7 +15,8 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import {
|
||||
useGetPendingAppealsQuery,
|
||||
@@ -34,7 +35,9 @@ import type { ExamAppeal } from '../../types/result';
|
||||
export function ExamAppealsPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const showDate = useDateDisplayer();
|
||||
const { data: appeals, isLoading, isError, refetch } = useGetPendingAppealsQuery();
|
||||
const table = useServerTable();
|
||||
const [decideAppeal, { isLoading: isDeciding }] = useDecideAppealMutation();
|
||||
|
||||
const [target, setTarget] = useState<ExamAppeal | null>(null);
|
||||
@@ -72,6 +75,8 @@ export function ExamAppealsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const paged = table.paginate(appeals ?? []);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<div>
|
||||
@@ -82,18 +87,22 @@ export function ExamAppealsPage() {
|
||||
</div>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<AdvancedTable
|
||||
columns={examAppealsColumns(t, locale, {
|
||||
<AdvancedTable<ExamAppeal>
|
||||
tableName={t('result.appeals.title')}
|
||||
columns={examAppealsColumns(t, locale, showDate, {
|
||||
onDecide: (appeal) => {
|
||||
setTarget(appeal);
|
||||
setOutcome('UPHELD');
|
||||
setRemark('');
|
||||
},
|
||||
})}
|
||||
data={appeals ?? []}
|
||||
rowKey={(appeal) => appeal.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('result.appeals.none')}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
refresh={refetch}
|
||||
emptyText={t('result.appeals.none')}
|
||||
/>
|
||||
</Paper>
|
||||
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import { Button, Group } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Result } from '../../types/result';
|
||||
|
||||
export type QcAction = 'moderate' | 'approve' | 'return';
|
||||
|
||||
export function resultActionsColumn(
|
||||
t: TFunction,
|
||||
handlers: {
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
},
|
||||
): AdvancedColumn<Result> {
|
||||
return {
|
||||
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={() => handlers.onQc(r, 'moderate')}>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button size="compact-xs" variant="light" color="blue" onClick={() => handlers.onQc(r, 'approve')}>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button size="compact-xs" variant="subtle" color="orange" onClick={() => handlers.onQc(r, 'return')}>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button size="xs" variant="subtle" leftSection={<IconEye size={13} />} onClick={() => handlers.onViewDetail(r)}>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import { Badge, Box, Button, Group, Text, TextInput } from '@mantine/core';
|
||||
import { IconEye, IconTrash } from '@tabler/icons-react';
|
||||
import { Badge, Box, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { Result, ResultBreakdown, ResultReviewStatus } from '../../types/result';
|
||||
import type { QuestionBrief } from '../../../exam/types/exam';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Result, ResultReviewStatus } from '../../types/result';
|
||||
|
||||
export const STATUS_COLOR: Record<string, string> = {
|
||||
PASSED: 'teal',
|
||||
@@ -18,204 +16,63 @@ export const REVIEW_COLOR: Record<ResultReviewStatus, string> = {
|
||||
PUBLISHED: 'teal',
|
||||
};
|
||||
|
||||
export type QcAction = 'moderate' | 'approve' | 'return';
|
||||
|
||||
export function resultColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
getExamTitle: (id: string) => string;
|
||||
onQc: (result: Result, action: QcAction) => void;
|
||||
onViewDetail: (result: Result) => void;
|
||||
onDelete: (result: Result) => void;
|
||||
},
|
||||
): AdvancedTableColumn<Result>[] {
|
||||
showDate: (date: string) => string,
|
||||
getExamTitle: (id: string) => string,
|
||||
): AdvancedColumn<Result>[] {
|
||||
return [
|
||||
{
|
||||
key: 'seafarer',
|
||||
header: t('result.columns.seafarer'),
|
||||
render: (r) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm" fw={500}>
|
||||
{r.seafarer ? `${r.seafarer.firstName} ${r.seafarer.lastName}` : r.seafarerId.slice(0, 8)}
|
||||
{row.original.seafarer
|
||||
? `${row.original.seafarer.firstName} ${row.original.seafarer.lastName}`
|
||||
: row.original.seafarerId.slice(0, 8)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'exam',
|
||||
header: t('result.columns.exam'),
|
||||
render: (r) => (
|
||||
<Text fz="sm">{r.exam ? r.exam.title[locale] : handlers.getExamTitle(r.examId)}</Text>
|
||||
cell: ({ row }) => (
|
||||
<Text fz="sm">{row.original.exam ? row.original.exam.title[locale] : getExamTitle(row.original.examId)}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'totalScore',
|
||||
header: t('result.columns.totalScore'),
|
||||
render: (r) => <Text fz="sm" fw={600}>{r.totalScore}</Text>,
|
||||
cell: ({ row }) => <Text fz="sm" fw={600}>{row.original.totalScore}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: t('result.columns.status'),
|
||||
render: (r) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={STATUS_COLOR[r.status]}
|
||||
color={STATUS_COLOR[row.original.status]}
|
||||
leftSection={
|
||||
<Box
|
||||
w={6}
|
||||
h={6}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[r.status]}-6)` }}
|
||||
style={{ borderRadius: 999, background: `var(--mantine-color-${STATUS_COLOR[row.original.status]}-6)` }}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{t(`result.status.${r.status}`)}
|
||||
{t(`result.status.${row.original.status}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'review',
|
||||
header: t('result.review.column'),
|
||||
render: (r) => (
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[r.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${r.reviewStatus}`)}
|
||||
cell: ({ row }) => (
|
||||
<Badge size="sm" variant="light" color={REVIEW_COLOR[row.original.reviewStatus] ?? 'gray'}>
|
||||
{t(`result.review.${row.original.reviewStatus}`)}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: t('result.columns.date'),
|
||||
render: (r) => <Text fz="sm">{new Date(r.createdAt).toLocaleDateString()}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (r) => (
|
||||
<Group gap="xs">
|
||||
{(r.reviewStatus === 'MARKED' || r.reviewStatus === 'MODERATED') && (
|
||||
<>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="yellow"
|
||||
onClick={() => handlers.onQc(r, 'moderate')}
|
||||
>
|
||||
{t('result.review.moderate')}
|
||||
</Button>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
color="blue"
|
||||
onClick={() => handlers.onQc(r, 'approve')}
|
||||
>
|
||||
{t('result.review.approve')}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{(r.reviewStatus === 'APPROVED' || r.reviewStatus === 'MODERATED') && (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
color="orange"
|
||||
onClick={() => handlers.onQc(r, 'return')}
|
||||
>
|
||||
{t('result.review.return')}
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconEye size={13} />}
|
||||
onClick={() => handlers.onViewDetail(r)}
|
||||
>
|
||||
{t('result.action.viewEdit')}
|
||||
</Button>
|
||||
<Button
|
||||
size="xs"
|
||||
variant="subtle"
|
||||
color="red"
|
||||
leftSection={<IconTrash size={13} />}
|
||||
onClick={() => handlers.onDelete(r)}
|
||||
>
|
||||
{t('result.action.delete')}
|
||||
</Button>
|
||||
</Group>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function resultBreakdownColumns(
|
||||
t: TFunction,
|
||||
locale: 'en' | 'am',
|
||||
handlers: {
|
||||
breakdowns: ResultBreakdown[];
|
||||
questions?: QuestionBrief[];
|
||||
onChange: (breakdowns: ResultBreakdown[]) => void;
|
||||
},
|
||||
): AdvancedTableColumn<ResultBreakdown>[] {
|
||||
const { breakdowns, questions, onChange } = handlers;
|
||||
const indexOf = (b: ResultBreakdown) =>
|
||||
breakdowns.findIndex((x) => x.questionId === b.questionId);
|
||||
return [
|
||||
{
|
||||
key: 'index',
|
||||
header: '#',
|
||||
render: (b) => <Text fz="xs">{indexOf(b) + 1}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'question',
|
||||
header: t('result.detail.question'),
|
||||
render: (b) => {
|
||||
const q = questions?.find((eq) => eq.id === b.questionId);
|
||||
return (
|
||||
<Text fz="xs" lineClamp={2} maw={200}>
|
||||
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
|
||||
</Text>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'max',
|
||||
header: t('result.detail.max'),
|
||||
render: (b) => {
|
||||
const q = questions?.find((eq) => eq.id === b.questionId);
|
||||
return <Text fz="sm" fw={600}>{q?.points ?? '—'}</Text>;
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: t('result.detail.score'),
|
||||
render: (b) => (
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
onChange={(e) => {
|
||||
const i = indexOf(b);
|
||||
const updated = [...breakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'remark',
|
||||
header: t('result.detail.remarkShort'),
|
||||
render: (b) => (
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
onChange={(e) => {
|
||||
const i = indexOf(b);
|
||||
const updated = [...breakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
onChange(updated);
|
||||
}}
|
||||
/>
|
||||
),
|
||||
cell: ({ row }) => <Text fz="sm">{showDate(row.original.createdAt)}</Text>,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
@@ -4,10 +4,12 @@ import {
|
||||
Stack,
|
||||
Title,
|
||||
Group,
|
||||
Table,
|
||||
Badge,
|
||||
Modal,
|
||||
Text,
|
||||
Paper,
|
||||
Card,
|
||||
Loader,
|
||||
Center,
|
||||
Alert,
|
||||
@@ -32,9 +34,10 @@ import {
|
||||
IconSearch,
|
||||
IconSend,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, BilingualInput } from '@ema-platform/ui';
|
||||
import { notify, BilingualInput, useErrorHandler, AdvancedTable, useServerTable, ModalFooter } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { extractErrorMessage } from '@ema-platform/api';
|
||||
import { extractErrorMessage, useLocalized } from '@ema-platform/api';
|
||||
import {
|
||||
useGetResultsQuery,
|
||||
useLazyGetResultQuery,
|
||||
@@ -47,15 +50,10 @@ import {
|
||||
} from '../../api/result-api';
|
||||
import { useGetExamsQuery } from '../../../exam/api/exam-api';
|
||||
import { RecordResultModal } from '../../components/RecordResultModal';
|
||||
import {
|
||||
STATUS_COLOR,
|
||||
REVIEW_COLOR,
|
||||
resultColumns,
|
||||
resultBreakdownColumns,
|
||||
type QcAction,
|
||||
} from './columns';
|
||||
import type { Result, ResultBreakdown } from '../../types/result';
|
||||
import type { Exam } from '../../../exam/types/exam';
|
||||
import { resultColumns, STATUS_COLOR, REVIEW_COLOR } from './columns';
|
||||
import { resultActionsColumn, type QcAction } from './actions';
|
||||
|
||||
function ResultStat({
|
||||
label,
|
||||
@@ -103,9 +101,13 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
|
||||
export function ResultPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
const showDate = useDateDisplayer();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError, refetch } = useGetResultsQuery();
|
||||
const { data, isFetching, isError, refetch } = useGetResultsQuery();
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
|
||||
const exams = examRes?.items ?? [];
|
||||
@@ -135,7 +137,7 @@ export function ResultPage() {
|
||||
const [qcRemark, setQcRemark] = useState('');
|
||||
const [qcAdjustment, setQcAdjustment] = useState(0);
|
||||
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${e.title.en} (${e.date})` }));
|
||||
const examOptions = exams.map((e) => ({ value: e.id, label: `${localized(e.title)} (${e.date})` }));
|
||||
|
||||
const startRecord = () => {
|
||||
const ex = exams.find((e) => e.id === pickerExamId);
|
||||
@@ -272,14 +274,24 @@ export function ResultPage() {
|
||||
notify.success(t('result.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('result.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
if (isLoading) return <Center py="xl"><Loader /></Center>;
|
||||
if (isError) return <Alert icon={<IconInfoCircle size={16} />} color="red" title={t('result.loadError')} />;
|
||||
|
||||
const columns = [
|
||||
...resultColumns(t, locale, showDate, getExamTitle),
|
||||
resultActionsColumn(t, {
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
}),
|
||||
];
|
||||
|
||||
const page = paginate(filtered);
|
||||
|
||||
return (
|
||||
<Stack gap="lg">
|
||||
<Group justify="space-between" align="flex-end">
|
||||
@@ -312,7 +324,7 @@ export function ResultPage() {
|
||||
<ResultStat label={t('result.stats.avgScore')} value={avgScore} icon={IconChartBar} color="indigo" />
|
||||
</SimpleGrid>
|
||||
|
||||
<Paper withBorder radius="md">
|
||||
<Card withBorder padding={0}>
|
||||
<Group p="md" justify="space-between" wrap="wrap" gap="sm">
|
||||
<Text fw={600}>{t('result.section')}</Text>
|
||||
<Group gap="sm" wrap="wrap">
|
||||
@@ -320,7 +332,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.seafarer')}
|
||||
leftSection={<IconSearch size={15} />}
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.currentTarget.value)}
|
||||
onChange={(e) => { setSearchQuery(e.currentTarget.value); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 240 }}
|
||||
/>
|
||||
@@ -328,7 +340,7 @@ export function ResultPage() {
|
||||
placeholder={t('result.search.filterByExam')}
|
||||
data={[{ value: '', label: t('result.search.allExams') }, ...examOptions]}
|
||||
value={examFilter}
|
||||
onChange={(v) => setExamFilter(v ?? null)}
|
||||
onChange={(v) => { setExamFilter(v ?? null); setPageIndex(0); }}
|
||||
size="sm"
|
||||
style={{ width: 280 }}
|
||||
clearable
|
||||
@@ -337,18 +349,19 @@ export function ResultPage() {
|
||||
</Group>
|
||||
|
||||
<AdvancedTable
|
||||
columns={resultColumns(t, locale, {
|
||||
getExamTitle,
|
||||
onQc: openQc,
|
||||
onViewDetail: viewDetail,
|
||||
onDelete: (r) => { setDeleteTarget(r); openDelete(); },
|
||||
})}
|
||||
data={filtered}
|
||||
rowKey={(r) => r.id}
|
||||
onRefresh={refetch}
|
||||
emptyTitle={t('result.noItems')}
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('result.title')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isFetching}
|
||||
emptyText={t('result.noItems')}
|
||||
/>
|
||||
</Paper>
|
||||
</Card>
|
||||
|
||||
<Modal
|
||||
opened={detailOpened}
|
||||
@@ -372,7 +385,7 @@ export function ResultPage() {
|
||||
<SimpleGrid cols={{ base: 2, sm: 4 }} spacing="sm">
|
||||
<InfoRow label={t('result.detail.fullName')} value={detailResult.profile ? `${detailResult.profile.firstName} ${detailResult.profile.middleName ?? ''} ${detailResult.profile.lastName}` : detailResult.seafarerId} />
|
||||
<InfoRow label={t('result.detail.gender')} value={detailResult.profile?.gender ?? '—'} />
|
||||
<InfoRow label={t('result.detail.dateOfBirth')} value={detailResult.profile?.dob ? new Date(detailResult.profile.dob).toLocaleDateString() : '—'} />
|
||||
<InfoRow label={t('result.detail.dateOfBirth')} value={showDate(detailResult.profile?.dob)} />
|
||||
<InfoRow label={t('result.detail.maritalStatus')} value={detailResult.profile?.maritalStatus ?? '—'} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
@@ -390,7 +403,7 @@ export function ResultPage() {
|
||||
<InfoRow label={t('result.detail.examTitle')} value={detailResult.exam.title?.[locale] ?? '—'} />
|
||||
<InfoRow label={t('result.detail.type')} value={detailResult.exam.type ?? '—'} />
|
||||
<InfoRow label={t('result.detail.venue')} value={detailResult.exam.venue ?? '—'} />
|
||||
<InfoRow label={t('result.detail.date')} value={detailResult.exam.date ? new Date(detailResult.exam.date).toLocaleDateString() : '—'} />
|
||||
<InfoRow label={t('result.detail.date')} value={showDate(detailResult.exam.date)} />
|
||||
<InfoRow label={t('result.detail.passMark')} value={String(detailResult.exam.cuttingPoint ?? 0)} />
|
||||
</SimpleGrid>
|
||||
</Paper>
|
||||
@@ -431,19 +444,62 @@ export function ResultPage() {
|
||||
{detailBreakdowns.length > 0 && (
|
||||
<>
|
||||
<Text fw={600} fz="sm" tt="uppercase" c="gray.6">{t('result.detail.scoreBreakdown')}</Text>
|
||||
<AdvancedTable
|
||||
columns={resultBreakdownColumns(t, locale, {
|
||||
breakdowns: detailBreakdowns,
|
||||
questions: exams.find((e) => e.id === detailResult.examId)?.questions,
|
||||
onChange: setDetailBreakdowns,
|
||||
})}
|
||||
data={detailBreakdowns}
|
||||
rowKey={(b) => b.questionId}
|
||||
/>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>#</Table.Th>
|
||||
<Table.Th>{t('result.detail.question')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.max')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.score')}</Table.Th>
|
||||
<Table.Th>{t('result.detail.remarkShort')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{detailBreakdowns.map((b, i) => {
|
||||
const examDetail = exams.find((e) => e.id === detailResult.examId);
|
||||
const q = examDetail?.questions?.find((eq) => eq.id === b.questionId);
|
||||
return (
|
||||
<Table.Tr key={b.questionId}>
|
||||
<Table.Td><Text fz="xs">{i + 1}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<Text fz="xs" lineClamp={2} maw={200}>
|
||||
{q?.title?.[locale] ?? b.questionId.slice(0, 8)}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td><Text fz="sm" fw={600}>{q?.points ?? '—'}</Text></Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
type="number"
|
||||
style={{ width: 80 }}
|
||||
value={b.score}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], score: Number(e.currentTarget.value) };
|
||||
setDetailBreakdowns(updated);
|
||||
}}
|
||||
/>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<TextInput
|
||||
size="xs"
|
||||
placeholder="Optional"
|
||||
value={b.remark ?? ''}
|
||||
onChange={(e) => {
|
||||
const updated = [...detailBreakdowns];
|
||||
updated[i] = { ...updated[i], remark: e.currentTarget.value || undefined };
|
||||
setDetailBreakdowns(updated);
|
||||
}}
|
||||
/>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
);})}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={handleDetailClose} size="sm">{t('result.close')}</Button>
|
||||
<Button
|
||||
onClick={handleDetailSave}
|
||||
@@ -453,7 +509,7 @@ export function ResultPage() {
|
||||
>
|
||||
{t('result.save')}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
) : (
|
||||
<Text c="dimmed" ta="center" py="xl">{t('result.noData')}</Text>
|
||||
@@ -509,10 +565,10 @@ export function ResultPage() {
|
||||
|
||||
<Modal opened={deleteOpened} onClose={closeDelete} title={t('result.confirmDelete')} size="sm">
|
||||
<Text mb="md">{t('result.deleteConfirmText')}</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closeDelete} size="sm">{t('result.cancel')}</Button>
|
||||
<Button color="red" onClick={handleDelete} size="sm">{t('result.delete')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Modal>
|
||||
|
||||
{/* Choose exam, then record */}
|
||||
@@ -529,10 +585,10 @@ export function ResultPage() {
|
||||
searchable
|
||||
required
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={closePicker} size="sm">{t('result.cancel')}</Button>
|
||||
<Button onClick={startRecord} size="sm" disabled={!pickerExamId}>{t('result.continue')}</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Button, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { ProfileRow } from './columns';
|
||||
|
||||
export function seafarerStatusActionColumn(
|
||||
t: TFunction,
|
||||
handlers: { onStatus: (profile: ProfileRow) => void },
|
||||
): AdvancedColumn<ProfileRow> {
|
||||
return {
|
||||
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={() => handlers.onStatus(row.original)}
|
||||
>
|
||||
{t('seafarerRegistry.statusAction', 'Status')}
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
};
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { MedicalCertificate, SeaServiceRecord } from '@ema-platform/api';
|
||||
import { Badge, Text } from '@mantine/core';
|
||||
import type { TFunction } from 'i18next';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
|
||||
export interface ProfileRow {
|
||||
id: string;
|
||||
@@ -26,165 +25,69 @@ export const SEAFARER_STATUS_COLORS: Record<string, string> = {
|
||||
INACTIVE: 'gray',
|
||||
};
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
export const DEPARTMENT_LABELS: Record<string, string> = {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
};
|
||||
|
||||
export function seafarerRegistryColumns(handlers: {
|
||||
onStatus: (profile: ProfileRow) => void;
|
||||
}): AdvancedTableColumn<ProfileRow>[] {
|
||||
export function seafarerRegistryColumns(
|
||||
t: TFunction,
|
||||
handlers: { onDetail: (profile: ProfileRow) => void },
|
||||
): AdvancedColumn<ProfileRow>[] {
|
||||
return [
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Name',
|
||||
render: (p) => (
|
||||
<Text size="sm" fw={500}>
|
||||
{[p.firstName, p.middleName, p.lastName].filter(Boolean).join(' ')}
|
||||
header: t('seafarerRegistry.columns.name', 'Name'),
|
||||
cell: ({ row }) => (
|
||||
<Text
|
||||
size="sm"
|
||||
fw={500}
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => handlers.onDetail(row.original)}
|
||||
>
|
||||
{[row.original.firstName, row.original.middleName, row.original.lastName].filter(Boolean).join(' ')}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'seafarerNumber',
|
||||
header: 'Seafarer №',
|
||||
render: (p) => (
|
||||
<Text size="sm" ff="monospace">
|
||||
{p.seafarerNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
header: t('seafarerRegistry.columns.number', 'Seafarer №'),
|
||||
cell: ({ row }) => <Text size="sm" ff="monospace">{row.original.seafarerNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'department',
|
||||
header: 'Department',
|
||||
render: (p) => (
|
||||
header: t('seafarerRegistry.columns.department', 'Department'),
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">
|
||||
{p.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[p.seafarerDepartment] ?? p.seafarerDepartment
|
||||
{row.original.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${row.original.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[row.original.seafarerDepartment] ?? row.original.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'idNumber',
|
||||
header: 'ID number',
|
||||
render: (p) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.idNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
header: t('seafarerRegistry.columns.idNumber', 'ID number'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.idNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'phone',
|
||||
header: 'Phone',
|
||||
render: (p) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{p.address?.primaryPhoneNumber ?? '—'}
|
||||
</Text>
|
||||
),
|
||||
header: t('seafarerRegistry.columns.phone', 'Phone'),
|
||||
cell: ({ row }) => <Text size="sm" c="dimmed">{row.original.address?.primaryPhoneNumber ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (p) =>
|
||||
p.seafarerNumber ? (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={SEAFARER_STATUS_COLORS[p.seafarerStatus ?? ''] ?? 'gray'}
|
||||
>
|
||||
{p.seafarerStatus}
|
||||
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={p.isComplete ? 'teal' : 'gray'}>
|
||||
{p.isComplete ? 'Not registered' : 'Incomplete'}
|
||||
<Badge size="sm" variant="light" color={row.original.isComplete ? 'teal' : 'gray'}>
|
||||
{row.original.isComplete
|
||||
? t('seafarerRegistry.notRegistered', 'Not registered')
|
||||
: t('seafarerRegistry.incomplete', 'Incomplete')}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (p) =>
|
||||
p.seafarerNumber ? (
|
||||
<Tooltip label="Suspend / reinstate / close">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onStatus(p);
|
||||
}}
|
||||
>
|
||||
Status
|
||||
</Button>
|
||||
</Tooltip>
|
||||
) : null,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export const seaServiceColumns: AdvancedTableColumn<SeaServiceRecord>[] = [
|
||||
{
|
||||
key: 'vessel',
|
||||
header: 'Vessel',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
IMO {record.imoNumber}
|
||||
</Text>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'rank', header: 'Rank' },
|
||||
{
|
||||
key: 'period',
|
||||
header: 'Period',
|
||||
render: (record) => (
|
||||
<>
|
||||
{record.engagementDate} → {record.dischargeDate}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (record) => (
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{record.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
export const medicalColumns: AdvancedTableColumn<MedicalCertificate>[] = [
|
||||
{ key: 'issuerName', header: 'Issuer' },
|
||||
{
|
||||
key: 'validity',
|
||||
header: 'Validity',
|
||||
render: (certificate) => (
|
||||
<>
|
||||
{certificate.issueDate} → {certificate.expiryDate}
|
||||
</>
|
||||
),
|
||||
},
|
||||
{ key: 'fitnessStatus', header: 'Fitness' },
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (certificate) => (
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[certificate.status]}>
|
||||
{certificate.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
];
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Badge,
|
||||
Button,
|
||||
@@ -6,17 +7,24 @@ import {
|
||||
Container,
|
||||
Drawer,
|
||||
Group,
|
||||
Loader,
|
||||
Modal,
|
||||
Select,
|
||||
Stack,
|
||||
Table,
|
||||
Tabs,
|
||||
Text,
|
||||
TextInput,
|
||||
Textarea,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconAnchor, IconSearch, IconStethoscope } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import {
|
||||
IconAnchor,
|
||||
IconSearch,
|
||||
IconStethoscope,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useApiQuery,
|
||||
@@ -27,11 +35,16 @@ import {
|
||||
import {
|
||||
DEPARTMENT_LABELS,
|
||||
SEAFARER_STATUS_COLORS,
|
||||
medicalColumns,
|
||||
seaServiceColumns,
|
||||
seafarerRegistryColumns,
|
||||
type ProfileRow,
|
||||
} from './columns';
|
||||
import { seafarerStatusActionColumn } from './actions';
|
||||
|
||||
const RECORD_STATUS_COLORS: Record<string, string> = {
|
||||
SUBMITTED: 'blue',
|
||||
VERIFIED: 'green',
|
||||
REJECTED: 'red',
|
||||
};
|
||||
|
||||
/** The registered seafarer's records, read-only (verification is module 06). */
|
||||
function SeafarerDetailDrawer({
|
||||
@@ -41,11 +54,13 @@ function SeafarerDetailDrawer({
|
||||
profile: ProfileRow | null;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const profileId = profile?.id ?? '';
|
||||
const { data: seaService, isLoading: loadingSea } =
|
||||
useGetSeaServiceForProfileQuery(profileId, { skip: !profileId });
|
||||
const { data: medical, isLoading: loadingMedical } =
|
||||
useGetMedicalForProfileQuery(profileId, { skip: !profileId });
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
@@ -66,7 +81,7 @@ function SeafarerDetailDrawer({
|
||||
<Group gap="xl">
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Seafarer number
|
||||
{t('seafarerRegistry.drawer.seafarerNumber', 'Seafarer number')}
|
||||
</Text>
|
||||
<Text fw={700} ff="monospace">
|
||||
{profile.seafarerNumber ?? '—'}
|
||||
@@ -74,62 +89,137 @@ function SeafarerDetailDrawer({
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Department
|
||||
{t('seafarerRegistry.drawer.department', 'Department')}
|
||||
</Text>
|
||||
<Text fw={600}>
|
||||
{profile.seafarerDepartment
|
||||
? DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment
|
||||
? t(
|
||||
`seafarerRegistry.departments.${profile.seafarerDepartment}`,
|
||||
DEPARTMENT_LABELS[profile.seafarerDepartment] ??
|
||||
profile.seafarerDepartment,
|
||||
)
|
||||
: '—'}
|
||||
</Text>
|
||||
</div>
|
||||
<div>
|
||||
<Text size="xs" c="dimmed" tt="uppercase">
|
||||
Status
|
||||
{t('seafarerRegistry.drawer.status', 'Status')}
|
||||
</Text>
|
||||
<Badge
|
||||
color={
|
||||
SEAFARER_STATUS_COLORS[profile.seafarerStatus ?? ''] ?? 'gray'
|
||||
}
|
||||
>
|
||||
{profile.seafarerStatus ?? 'NOT REGISTERED'}
|
||||
{profile.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: t('seafarerRegistry.drawer.notRegistered', 'NOT REGISTERED')}
|
||||
</Badge>
|
||||
</div>
|
||||
</Group>
|
||||
{profile.seafarerStatusReason && (
|
||||
<Text size="sm" c="dimmed">
|
||||
Status reason: {profile.seafarerStatusReason}
|
||||
{t('seafarerRegistry.drawer.statusReason', {
|
||||
reason: profile.seafarerStatusReason,
|
||||
defaultValue: 'Status reason: {{reason}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
|
||||
<Tabs defaultValue="sea-service" keepMounted={false}>
|
||||
<Tabs.List>
|
||||
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={14} />}>
|
||||
Sea Service
|
||||
{t('seafarerRegistry.drawer.seaServiceTab', 'Sea Service')}
|
||||
</Tabs.Tab>
|
||||
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={14} />}>
|
||||
Medical
|
||||
{t('seafarerRegistry.drawer.medicalTab', 'Medical')}
|
||||
</Tabs.Tab>
|
||||
</Tabs.List>
|
||||
|
||||
<Tabs.Panel value="sea-service" pt="sm">
|
||||
<AdvancedTable
|
||||
columns={seaServiceColumns}
|
||||
data={seaService ?? []}
|
||||
rowKey={(record) => record.id}
|
||||
loading={loadingSea}
|
||||
emptyTitle="No sea-service records."
|
||||
/>
|
||||
{loadingSea ? (
|
||||
<Loader size="sm" />
|
||||
) : (seaService ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.noSeaService', 'No sea-service records.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.vessel', 'Vessel')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.rank', 'Rank')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.period', 'Period')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(seaService ?? []).map((record) => (
|
||||
<Table.Tr key={record.id}>
|
||||
<Table.Td>
|
||||
{record.vesselName}
|
||||
{record.imoNumber && (
|
||||
<Text size="xs" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.imoPrefix', {
|
||||
number: record.imoNumber,
|
||||
defaultValue: 'IMO {{number}}',
|
||||
})}
|
||||
</Text>
|
||||
)}
|
||||
</Table.Td>
|
||||
<Table.Td>{record.rank}</Table.Td>
|
||||
<Table.Td>
|
||||
{showDate(record.engagementDate)} → {showDate(record.dischargeDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge size="sm" color={RECORD_STATUS_COLORS[record.status]}>
|
||||
{t(`seafarerRegistry.recordStatus.${record.status}`, record.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
|
||||
<Tabs.Panel value="medical" pt="sm">
|
||||
<AdvancedTable
|
||||
columns={medicalColumns}
|
||||
data={medical ?? []}
|
||||
rowKey={(certificate) => certificate.id}
|
||||
loading={loadingMedical}
|
||||
emptyTitle="No medical certificates."
|
||||
/>
|
||||
{loadingMedical ? (
|
||||
<Loader size="sm" />
|
||||
) : (medical ?? []).length === 0 ? (
|
||||
<Text size="sm" c="dimmed">
|
||||
{t('seafarerRegistry.drawer.noMedical', 'No medical certificates.')}
|
||||
</Text>
|
||||
) : (
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.issuer', 'Issuer')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.validity', 'Validity')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.fitness', 'Fitness')}</Table.Th>
|
||||
<Table.Th>{t('seafarerRegistry.drawer.status', 'Status')}</Table.Th>
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{(medical ?? []).map((certificate) => (
|
||||
<Table.Tr key={certificate.id}>
|
||||
<Table.Td>{certificate.issuerName}</Table.Td>
|
||||
<Table.Td>
|
||||
{showDate(certificate.issueDate)} → {showDate(certificate.expiryDate)}
|
||||
</Table.Td>
|
||||
<Table.Td>{certificate.fitnessStatus}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
color={RECORD_STATUS_COLORS[certificate.status]}
|
||||
>
|
||||
{t(`seafarerRegistry.recordStatus.${certificate.status}`, certificate.status)}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
)}
|
||||
</Tabs.Panel>
|
||||
</Tabs>
|
||||
</Stack>
|
||||
@@ -148,6 +238,7 @@ function StatusModal({
|
||||
onClose: () => void;
|
||||
onDone: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const [status, setStatus] = useState<string | null>(null);
|
||||
const [reason, setReason] = useState('');
|
||||
const [updateStatus, { isLoading }] = useUpdateSeafarerStatusMutation();
|
||||
@@ -160,11 +251,13 @@ function StatusModal({
|
||||
status: status as 'ACTIVE' | 'INACTIVE' | 'SUSPENDED' | 'PENDING',
|
||||
reason,
|
||||
}).unwrap();
|
||||
notify.success('Seafarer status updated');
|
||||
notify.success(t('seafarerRegistry.modal.updated', 'Seafarer status updated'));
|
||||
onClose();
|
||||
onDone();
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not update the status'));
|
||||
notify.error(
|
||||
extractErrorMessage(error, t('seafarerRegistry.modal.updateFailed', 'Could not update the status')),
|
||||
);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -172,27 +265,33 @@ function StatusModal({
|
||||
<Modal
|
||||
opened={Boolean(profile)}
|
||||
onClose={onClose}
|
||||
title="Change seafarer status"
|
||||
title={t('seafarerRegistry.modal.title', 'Change seafarer status')}
|
||||
centered
|
||||
>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
{profile?.seafarerNumber} — currently {profile?.seafarerStatus}. The
|
||||
reason is recorded and visible to the seafarer.
|
||||
{t('seafarerRegistry.modal.body', {
|
||||
number: profile?.seafarerNumber,
|
||||
status: profile?.seafarerStatus
|
||||
? t(`seafarerRegistry.status.${profile.seafarerStatus}`, profile.seafarerStatus)
|
||||
: profile?.seafarerStatus,
|
||||
defaultValue:
|
||||
'{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
||||
})}
|
||||
</Text>
|
||||
<Select
|
||||
label="New status"
|
||||
label={t('seafarerRegistry.modal.newStatus', 'New status')}
|
||||
required
|
||||
data={[
|
||||
{ value: 'SUSPENDED', label: 'Suspend' },
|
||||
{ value: 'INACTIVE', label: 'Close' },
|
||||
{ value: 'ACTIVE', label: 'Reinstate' },
|
||||
{ value: 'SUSPENDED', label: t('seafarerRegistry.modal.suspend', 'Suspend') },
|
||||
{ value: 'INACTIVE', label: t('seafarerRegistry.modal.close', 'Close') },
|
||||
{ value: 'ACTIVE', label: t('seafarerRegistry.modal.reinstate', 'Reinstate') },
|
||||
].filter((o) => o.value !== profile?.seafarerStatus)}
|
||||
value={status}
|
||||
onChange={setStatus}
|
||||
/>
|
||||
<Textarea
|
||||
label="Reason"
|
||||
label={t('seafarerRegistry.modal.reason', 'Reason')}
|
||||
required
|
||||
minRows={2}
|
||||
value={reason}
|
||||
@@ -200,7 +299,7 @@ function StatusModal({
|
||||
/>
|
||||
<Group justify="flex-end">
|
||||
<Button variant="default" onClick={onClose}>
|
||||
Cancel
|
||||
{t('seafarerRegistry.modal.cancel', 'Cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
color={status === 'ACTIVE' ? 'green' : 'orange'}
|
||||
@@ -208,7 +307,7 @@ function StatusModal({
|
||||
loading={isLoading}
|
||||
onClick={submit}
|
||||
>
|
||||
Confirm
|
||||
{t('seafarerRegistry.modal.confirm', 'Confirm')}
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
@@ -224,6 +323,7 @@ function StatusModal({
|
||||
* register — numbers, departments, statuses, and each seafarer's records.
|
||||
*/
|
||||
export function SeafarerRegistryPage() {
|
||||
const { t } = useTranslation();
|
||||
const [search, setSearch] = useState('');
|
||||
const [detail, setDetail] = useState<ProfileRow | null>(null);
|
||||
const [statusTarget, setStatusTarget] = useState<ProfileRow | null>(null);
|
||||
@@ -235,6 +335,7 @@ export function SeafarerRegistryPage() {
|
||||
method: 'GET',
|
||||
params: { q: 'i=profession,address&t=200' },
|
||||
});
|
||||
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
|
||||
|
||||
const items = (data?.items ?? []).filter((p) => {
|
||||
if (!search.trim()) return true;
|
||||
@@ -249,18 +350,27 @@ export function SeafarerRegistryPage() {
|
||||
.filter(Boolean)
|
||||
.some((v) => String(v).toLowerCase().includes(term));
|
||||
});
|
||||
const page = paginate(items);
|
||||
|
||||
const columns = [
|
||||
...seafarerRegistryColumns(t, { onDetail: setDetail }),
|
||||
seafarerStatusActionColumn(t, { onStatus: setStatusTarget }),
|
||||
];
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
<Group justify="space-between" mb="md">
|
||||
<div>
|
||||
<Title order={3}>Seafarer registry</Title>
|
||||
<Title order={3}>{t('seafarerRegistry.title', 'Seafarer registry')}</Title>
|
||||
<Text size="sm" c="dimmed">
|
||||
{data?.total ?? 0} profile{(data?.total ?? 0) === 1 ? '' : 's'}
|
||||
{t('seafarerRegistry.profileCount', {
|
||||
count: data?.total ?? 0,
|
||||
defaultValue: '{{count}} profile(s)',
|
||||
})}
|
||||
</Text>
|
||||
</div>
|
||||
<TextInput
|
||||
placeholder="Name, ID or seafarer number"
|
||||
placeholder={t('seafarerRegistry.searchPlaceholder', 'Name, ID or seafarer number')}
|
||||
leftSection={<IconSearch size={14} />}
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.currentTarget.value)}
|
||||
@@ -270,14 +380,20 @@ export function SeafarerRegistryPage() {
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={seafarerRegistryColumns({ onStatus: setStatusTarget })}
|
||||
data={items}
|
||||
rowKey={(p) => p.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
onRowClick={(p) => setDetail(p)}
|
||||
emptyTitle={
|
||||
search ? 'No profiles match that search.' : 'No seafarers registered yet.'
|
||||
columns={columns}
|
||||
data={page.rows}
|
||||
tableName={t('seafarerRegistry.title', 'Seafarer registry')}
|
||||
itemCount={page.itemCount}
|
||||
pageIndex={page.pageIndex}
|
||||
onPageChange={setPageIndex}
|
||||
pageSize={pageSize}
|
||||
onPageSizeChange={setPageSize}
|
||||
refresh={refetch}
|
||||
isLoading={isLoading}
|
||||
emptyText={
|
||||
search
|
||||
? t('seafarerRegistry.emptySearch', 'No profiles match that search.')
|
||||
: t('seafarerRegistry.emptyNone', 'No seafarers registered yet.')
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import Cookies from 'js-cookie';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
@@ -138,8 +139,8 @@ export default function UserManagementPage() {
|
||||
style.textContent = UM_OVERRIDES;
|
||||
document.head.appendChild(style);
|
||||
|
||||
const token = localStorage.getItem('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = localStorage.getItem('ema-backoffice-refresh-token') ?? undefined;
|
||||
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
|
||||
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
|
||||
|
||||
const session: UserManagementSessionOptions = {
|
||||
initialSession: token
|
||||
|
||||
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal file
433
apps/backoffice/src/app/features/vessel-registration/mock.ts
Normal file
@@ -0,0 +1,433 @@
|
||||
// ponytail: in-memory mock array, resets on reload — swap for RTK Query endpoint when backend lands.
|
||||
|
||||
export type VesselCategory = 'Inland Waterway' | 'Sea-going';
|
||||
|
||||
export type RegistrationStatus =
|
||||
| 'Pending'
|
||||
| 'Under Review'
|
||||
| 'Correction Required'
|
||||
| 'Resubmitted'
|
||||
| 'Approved'
|
||||
| 'Rejected';
|
||||
|
||||
export const STATUS_COLOR: Record<RegistrationStatus, string> = {
|
||||
Pending: 'gray',
|
||||
'Under Review': 'blue',
|
||||
'Correction Required': 'orange',
|
||||
Resubmitted: 'cyan',
|
||||
Approved: 'teal',
|
||||
Rejected: 'red',
|
||||
};
|
||||
|
||||
export type RenewalState = 'OK' | 'Due Soon' | 'Overdue';
|
||||
|
||||
export const RENEWAL_COLOR: Record<RenewalState, string> = {
|
||||
OK: 'gray',
|
||||
'Due Soon': 'orange',
|
||||
Overdue: 'red',
|
||||
};
|
||||
|
||||
export interface RegistrationOwner {
|
||||
name: string;
|
||||
idOrTin: string;
|
||||
phone: string;
|
||||
email: string;
|
||||
address: string;
|
||||
}
|
||||
|
||||
export interface TimelineStep {
|
||||
date: string | null;
|
||||
event: string;
|
||||
done: boolean;
|
||||
}
|
||||
|
||||
export interface RegistrationCertificate {
|
||||
name: string;
|
||||
number: string;
|
||||
issueDate: string;
|
||||
}
|
||||
|
||||
export interface RegistrationDocument {
|
||||
key: string;
|
||||
label: string;
|
||||
fileName: string;
|
||||
fileType: 'pdf' | 'image';
|
||||
}
|
||||
|
||||
export interface VesselRegistration {
|
||||
id: string;
|
||||
category: VesselCategory;
|
||||
status: RegistrationStatus;
|
||||
submitted: string;
|
||||
remarks?: string;
|
||||
correctionFields?: string[];
|
||||
expiryDate?: string;
|
||||
renewal?: RenewalState;
|
||||
timeline: TimelineStep[];
|
||||
documents: RegistrationDocument[];
|
||||
certificates?: RegistrationCertificate[];
|
||||
|
||||
// Vessel details
|
||||
vesselName: string;
|
||||
vesselType: string;
|
||||
registrationArea: string;
|
||||
flagState: string;
|
||||
passengerCapacity?: string;
|
||||
grossTonnage?: string;
|
||||
length: string;
|
||||
breadth: string;
|
||||
depth: string;
|
||||
|
||||
// Technical
|
||||
imoNumber?: string;
|
||||
hullNumber?: string;
|
||||
shipyard: string;
|
||||
yearBuilt: string;
|
||||
engineType: string;
|
||||
engineNumber: string;
|
||||
enginePower: string;
|
||||
hullMaterial: string;
|
||||
|
||||
// Ownership
|
||||
owner: RegistrationOwner;
|
||||
}
|
||||
|
||||
export const CERTIFICATES: Record<VesselCategory, string[]> = {
|
||||
'Inland Waterway': ['Inland Vessel Registration Certificate'],
|
||||
'Sea-going': [
|
||||
'Certificate of Nationality',
|
||||
'Certificate of Ownership',
|
||||
'Certificate of Registration',
|
||||
'Minimum Safe Manning Certificate',
|
||||
],
|
||||
};
|
||||
|
||||
const SUBMITTED_STEP = (date: string): TimelineStep => ({ date, event: 'Application Submitted', done: true });
|
||||
const PENDING_STEP = (event: string): TimelineStep => ({ date: null, event, done: false });
|
||||
|
||||
export const MOCK_REGISTRATIONS: VesselRegistration[] = [
|
||||
{
|
||||
id: 'VR-2025-0001',
|
||||
category: 'Sea-going',
|
||||
status: 'Under Review',
|
||||
submitted: '2025-06-20',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-20'),
|
||||
{ date: '2025-06-22', event: 'Document Verification', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'nile_star_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'nile_star_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'nile_star_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'nile_star_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
vesselName: 'MV Nile Star',
|
||||
vesselType: 'Bulk Carrier',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '18500',
|
||||
length: '190',
|
||||
breadth: '28',
|
||||
depth: '15',
|
||||
imoNumber: 'IMO9876543',
|
||||
shipyard: 'Hyundai Heavy Industries',
|
||||
yearBuilt: '2016',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-44210',
|
||||
enginePower: '12000 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Nile Shipping PLC',
|
||||
idOrTin: 'TIN-0012345678',
|
||||
phone: '+251911223344',
|
||||
email: 'ops@nileshipping.et',
|
||||
address: 'Bole Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0002',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Correction Required',
|
||||
submitted: '2025-06-10',
|
||||
remarks: 'Vessel photos are blurry — please re-upload at least 2 clear photos showing the hull and registration markings.',
|
||||
correctionFields: ['Vessel Photos'],
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-10'),
|
||||
{ date: '2025-06-12', event: 'Document Verification', done: true },
|
||||
{ date: '2025-06-14', event: 'Correction Requested', done: true },
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'tana_ferry3_photos.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Tana Ferry 3',
|
||||
vesselType: 'Ferry',
|
||||
registrationArea: 'Lake Tana',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '40',
|
||||
length: '18',
|
||||
breadth: '5',
|
||||
depth: '2',
|
||||
hullNumber: 'HN-2211',
|
||||
shipyard: 'Bahir Dar Boat Works',
|
||||
yearBuilt: '2020',
|
||||
engineType: 'Outboard',
|
||||
engineNumber: 'ENG-9931',
|
||||
enginePower: '150 hp',
|
||||
hullMaterial: 'Fiberglass',
|
||||
owner: {
|
||||
name: 'Getachew Alemu',
|
||||
idOrTin: 'ID-4455667788',
|
||||
phone: '+251922334455',
|
||||
email: 'getachew.alemu@example.com',
|
||||
address: 'Bahir Dar, Amhara',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0003',
|
||||
category: 'Sea-going',
|
||||
status: 'Approved',
|
||||
submitted: '2025-04-05',
|
||||
expiryDate: '2026-08-15',
|
||||
renewal: 'Due Soon',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-04-05'),
|
||||
{ date: '2025-04-08', event: 'Document Verification', done: true },
|
||||
{ date: '2025-04-20', event: 'Inspection', done: true },
|
||||
{ date: '2025-04-28', event: 'Approval', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'abay_voyager_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'abay_voyager_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'abay_voyager_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'abay_voyager_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
certificates: [
|
||||
{ name: 'Certificate of Nationality', number: 'CN-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Certificate of Ownership', number: 'CO-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Certificate of Registration', number: 'CR-2025-0091', issueDate: '2025-04-28' },
|
||||
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2025-0091', issueDate: '2025-04-28' },
|
||||
],
|
||||
vesselName: 'MV Abay Voyager',
|
||||
vesselType: 'General Cargo',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '9600',
|
||||
length: '140',
|
||||
breadth: '21',
|
||||
depth: '11',
|
||||
imoNumber: 'IMO9123456',
|
||||
shipyard: 'Damen Shipyards',
|
||||
yearBuilt: '2012',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-33012',
|
||||
enginePower: '7200 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Abay Maritime PLC',
|
||||
idOrTin: 'TIN-0098765432',
|
||||
phone: '+251933445566',
|
||||
email: 'contact@abaymaritime.et',
|
||||
address: 'Kirkos Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0004',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Rejected',
|
||||
submitted: '2025-03-02',
|
||||
remarks: 'Hull number does not match the submitted proof of ownership. Application rejected — please reapply with matching documentation.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-03-02'),
|
||||
{ date: '2025-03-05', event: 'Document Verification', done: true },
|
||||
{ date: '2025-03-14', event: 'Inspection', done: true },
|
||||
{ date: '2025-03-18', event: 'Rejected', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'awash_cargo1_photos.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Awash Cargo 1',
|
||||
vesselType: 'Cargo Barge',
|
||||
registrationArea: 'Awash River Basin',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '0',
|
||||
length: '22',
|
||||
breadth: '6',
|
||||
depth: '3',
|
||||
hullNumber: 'HN-1187',
|
||||
shipyard: 'Awash River Works',
|
||||
yearBuilt: '2018',
|
||||
engineType: 'Inboard',
|
||||
engineNumber: 'ENG-5567',
|
||||
enginePower: '210 hp',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Selam Tesfaye',
|
||||
idOrTin: 'ID-2233445566',
|
||||
phone: '+251944556677',
|
||||
email: 'selam.tesfaye@example.com',
|
||||
address: 'Adama, Oromia',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0005',
|
||||
category: 'Sea-going',
|
||||
status: 'Pending',
|
||||
submitted: '2025-07-10',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-07-10'),
|
||||
PENDING_STEP('Document Verification'),
|
||||
PENDING_STEP('Inspection'),
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'genale_pearl_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'genale_pearl_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'genale_pearl_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'genale_pearl_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
vesselName: 'MV Genale Pearl',
|
||||
vesselType: 'Container Ship',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '24300',
|
||||
length: '210',
|
||||
breadth: '30',
|
||||
depth: '17',
|
||||
imoNumber: 'IMO9345678',
|
||||
shipyard: 'Samsung Heavy Industries',
|
||||
yearBuilt: '2019',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-51290',
|
||||
enginePower: '15400 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Genale Maritime PLC',
|
||||
idOrTin: 'TIN-0011223344',
|
||||
phone: '+251911998877',
|
||||
email: 'ops@genalemaritime.et',
|
||||
address: 'Kirkos Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0006',
|
||||
category: 'Inland Waterway',
|
||||
status: 'Resubmitted',
|
||||
submitted: '2025-06-01',
|
||||
remarks: 'Re-uploaded clearer hull and deck photos as requested.',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2025-06-01'),
|
||||
{ date: '2025-06-03', event: 'Document Verification', done: true },
|
||||
{ date: '2025-06-05', event: 'Correction Requested', done: true },
|
||||
{ date: '2025-06-18', event: 'Resubmitted', done: true },
|
||||
PENDING_STEP('Approval'),
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'zeway_runner_photos_v2.jpg', fileType: 'image' },
|
||||
],
|
||||
vesselName: 'Zeway Runner',
|
||||
vesselType: 'Passenger Boat',
|
||||
registrationArea: 'Lake Ziway',
|
||||
flagState: 'Ethiopia',
|
||||
passengerCapacity: '25',
|
||||
length: '14',
|
||||
breadth: '4',
|
||||
depth: '1.6',
|
||||
hullNumber: 'HN-3092',
|
||||
shipyard: 'Ziway Boat Works',
|
||||
yearBuilt: '2021',
|
||||
engineType: 'Outboard',
|
||||
engineNumber: 'ENG-7712',
|
||||
enginePower: '90 hp',
|
||||
hullMaterial: 'Fiberglass',
|
||||
owner: {
|
||||
name: 'Mekdes Yohannes',
|
||||
idOrTin: 'ID-5566778899',
|
||||
phone: '+251955667788',
|
||||
email: 'mekdes.y@example.com',
|
||||
address: 'Ziway, Oromia',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'VR-2025-0007',
|
||||
category: 'Sea-going',
|
||||
status: 'Approved',
|
||||
submitted: '2024-08-01',
|
||||
expiryDate: '2025-08-01',
|
||||
renewal: 'Overdue',
|
||||
timeline: [
|
||||
SUBMITTED_STEP('2024-08-01'),
|
||||
{ date: '2024-08-05', event: 'Document Verification', done: true },
|
||||
{ date: '2024-08-18', event: 'Inspection', done: true },
|
||||
{ date: '2024-08-25', event: 'Approval', done: true },
|
||||
],
|
||||
documents: [
|
||||
{ key: 'photos', label: 'Vessel Photos', fileName: 'red_sea_trader_photos.pdf', fileType: 'pdf' },
|
||||
{ key: 'ownership', label: 'Proof of Ownership / Bill of Sale', fileName: 'red_sea_trader_bill_of_sale.pdf', fileType: 'pdf' },
|
||||
{ key: 'particulars', label: 'Ship Particulars', fileName: 'red_sea_trader_particulars.pdf', fileType: 'pdf' },
|
||||
{ key: 'insurance', label: 'Insurance Certificate', fileName: 'red_sea_trader_insurance.pdf', fileType: 'pdf' },
|
||||
],
|
||||
certificates: [
|
||||
{ name: 'Certificate of Nationality', number: 'CN-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Certificate of Ownership', number: 'CO-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Certificate of Registration', number: 'CR-2024-0058', issueDate: '2024-08-25' },
|
||||
{ name: 'Minimum Safe Manning Certificate', number: 'MSM-2024-0058', issueDate: '2024-08-25' },
|
||||
],
|
||||
vesselName: 'MV Red Sea Trader',
|
||||
vesselType: 'Tanker',
|
||||
registrationArea: 'Djibouti Corridor',
|
||||
flagState: 'Ethiopia',
|
||||
grossTonnage: '31200',
|
||||
length: '228',
|
||||
breadth: '32',
|
||||
depth: '19',
|
||||
imoNumber: 'IMO9456789',
|
||||
shipyard: 'Mitsubishi Heavy Industries',
|
||||
yearBuilt: '2009',
|
||||
engineType: 'Diesel',
|
||||
engineNumber: 'ENG-28871',
|
||||
enginePower: '18900 kW',
|
||||
hullMaterial: 'Steel',
|
||||
owner: {
|
||||
name: 'Red Sea Tankers PLC',
|
||||
idOrTin: 'TIN-0055443322',
|
||||
phone: '+251911332211',
|
||||
email: 'fleet@redseatankers.et',
|
||||
address: 'Yeka Sub-city, Addis Ababa',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// ponytail: mutate mock, no live clock — swap for real API mutation when backend lands.
|
||||
export function applyDecision(
|
||||
reg: VesselRegistration,
|
||||
status: RegistrationStatus,
|
||||
remarks?: string,
|
||||
correctionFields?: string[]
|
||||
): void {
|
||||
reg.status = status;
|
||||
if (remarks) reg.remarks = remarks;
|
||||
reg.correctionFields = status === 'Correction Required' ? correctionFields : undefined;
|
||||
const eventLabel: Record<RegistrationStatus, string> = {
|
||||
Pending: 'Pending',
|
||||
'Under Review': 'Marked Under Review',
|
||||
'Correction Required': 'Correction Requested',
|
||||
Resubmitted: 'Resubmitted',
|
||||
Approved: 'Approval',
|
||||
Rejected: 'Rejected',
|
||||
};
|
||||
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
|
||||
reg.timeline.push({ date: today, event: eventLabel[status], done: true });
|
||||
}
|
||||
|
||||
export function generateCertificates(reg: VesselRegistration): void {
|
||||
const today = reg.timeline[reg.timeline.length - 1]?.date ?? reg.submitted;
|
||||
reg.certificates = CERTIFICATES[reg.category].map((name, i) => ({
|
||||
name,
|
||||
number: `${name.split(' ').map((w) => w[0]).join('').toUpperCase()}-2025-${String(1000 + i)}`,
|
||||
issueDate: today ?? reg.submitted,
|
||||
}));
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferQueuePage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer queue"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferQueuePage;
|
||||
@@ -1,21 +0,0 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
*/
|
||||
export function VesselOwnershipTransferReviewPage() {
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Ownership transfer review"
|
||||
description="Vessel ownership transfer is not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
);
|
||||
}
|
||||
|
||||
export default VesselOwnershipTransferReviewPage;
|
||||
@@ -30,7 +30,7 @@ import {
|
||||
IconSettings,
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, ModalFooter } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -495,12 +495,12 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
onChange={(e) => setDraftRequired(e.currentTarget.checked)}
|
||||
/>
|
||||
<Divider />
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDrawerOpen(false)}>Cancel</Button>
|
||||
<Button color="teal" onClick={saveField}>
|
||||
{isNew ? 'Add Field' : 'Save Changes'}
|
||||
</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Drawer>
|
||||
|
||||
@@ -516,10 +516,10 @@ export function VesselRegistrationFormBuilderPage() {
|
||||
Are you sure you want to remove <strong>{deleteTarget?.label}</strong> from the form?
|
||||
This cannot be undone.
|
||||
</Text>
|
||||
<Group justify="flex-end">
|
||||
<ModalFooter>
|
||||
<Button variant="default" onClick={() => setDeleteTarget(null)}>Cancel</Button>
|
||||
<Button color="red" onClick={confirmDelete}>Delete Field</Button>
|
||||
</Group>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Stack>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { Badge, Button, Text, Tooltip } from '@mantine/core';
|
||||
import { IconShieldCog } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Vessel } from '@ema-platform/api';
|
||||
|
||||
const VESSEL_STATUS_COLORS: Record<string, string> = {
|
||||
@@ -14,66 +14,68 @@ export const CATEGORY_LABELS: Record<string, string> = {
|
||||
SEA_GOING: 'Sea-going',
|
||||
};
|
||||
|
||||
export function vesselRegistrationQueueColumns(handlers: {
|
||||
onStatus: (vessel: Vessel) => void;
|
||||
}): AdvancedTableColumn<Vessel>[] {
|
||||
export function vesselRegistrationQueueColumns(
|
||||
showDate: (value: string | number | Date | null | undefined) => string,
|
||||
handlers: {
|
||||
onStatus: (vessel: Vessel) => void;
|
||||
},
|
||||
): AdvancedColumn<Vessel>[] {
|
||||
return [
|
||||
{
|
||||
key: 'registrationNumber',
|
||||
header: 'Registration №',
|
||||
render: (vessel) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" ff="monospace" fw={600}>
|
||||
{vessel.registrationNumber}
|
||||
{row.original.registrationNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'name',
|
||||
header: 'Vessel',
|
||||
render: (vessel) => (
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={500}>
|
||||
{vessel.name}
|
||||
{row.original.name}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{vessel.vesselType ?? '—'}
|
||||
{vessel.imoNumber ? ` · IMO ${vessel.imoNumber}` : ''}
|
||||
{row.original.vesselType ?? '—'}
|
||||
{row.original.imoNumber ? ` · IMO ${row.original.imoNumber}` : ''}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'category',
|
||||
header: 'Category',
|
||||
render: (vessel) => CATEGORY_LABELS[vessel.category] ?? vessel.category,
|
||||
cell: ({ row }) => CATEGORY_LABELS[row.original.category] ?? row.original.category,
|
||||
},
|
||||
{
|
||||
key: 'ownerName',
|
||||
header: 'Owner',
|
||||
render: (vessel) => <Text size="sm">{vessel.ownerName ?? '—'}</Text>,
|
||||
cell: ({ row }) => <Text size="sm">{row.original.ownerName ?? '—'}</Text>,
|
||||
},
|
||||
{
|
||||
key: 'registeredAt',
|
||||
header: 'Registered',
|
||||
render: (vessel) => (
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm" c="dimmed">
|
||||
{vessel.registeredAt?.slice(0, 10)}
|
||||
{showDate(row.original.registeredAt)}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (vessel) => (
|
||||
<Badge size="sm" variant="light" color={VESSEL_STATUS_COLORS[vessel.status]}>
|
||||
{vessel.status}
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={VESSEL_STATUS_COLORS[row.original.status]}
|
||||
>
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'actions',
|
||||
header: '',
|
||||
render: (vessel) => (
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Tooltip label="Suspend / deregister / reinstate">
|
||||
<Button
|
||||
size="compact-xs"
|
||||
@@ -81,7 +83,7 @@ export function vesselRegistrationQueueColumns(handlers: {
|
||||
leftSection={<IconShieldCog size={14} />}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handlers.onStatus(vessel);
|
||||
handlers.onStatus(row.original);
|
||||
}}
|
||||
>
|
||||
Status
|
||||
|
||||
@@ -23,7 +23,8 @@ import {
|
||||
IconInfoCircle,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useGetVesselIncidentsQuery,
|
||||
@@ -46,6 +47,7 @@ function VesselDetailDrawer({
|
||||
}) {
|
||||
const { data: incidents, isLoading: loadingIncidents } =
|
||||
useGetVesselIncidentsQuery(vessel?.id ?? '', { skip: !vessel });
|
||||
const showDate = useDateDisplayer();
|
||||
|
||||
const particulars: [string, string | number | null][] = vessel
|
||||
? [
|
||||
@@ -65,7 +67,7 @@ function VesselDetailDrawer({
|
||||
['Engines', vessel.numberOfEngines],
|
||||
['Hull material', vessel.hullMaterial],
|
||||
['Owner', vessel.ownerName],
|
||||
['Registered', vessel.registeredAt?.slice(0, 10) ?? null],
|
||||
['Registered', vessel.registeredAt ? showDate(vessel.registeredAt) : null],
|
||||
]
|
||||
: [];
|
||||
|
||||
@@ -117,7 +119,7 @@ function VesselDetailDrawer({
|
||||
<Card key={incident.id} withBorder radius="md" p="sm">
|
||||
<Group justify="space-between">
|
||||
<Text size="sm" fw={600}>
|
||||
{incident.occurredAt}
|
||||
{showDate(incident.occurredAt)}
|
||||
{incident.location ? ` — ${incident.location}` : ''}
|
||||
</Text>
|
||||
<Badge size="sm" variant="light">
|
||||
@@ -230,6 +232,9 @@ export function VesselRegistrationQueuePage() {
|
||||
const [statusTarget, setStatusTarget] = useState<Vessel | null>(null);
|
||||
|
||||
const items = data?.items ?? [];
|
||||
const showDate = useDateDisplayer();
|
||||
const table = useServerTable();
|
||||
const paged = table.paginate(items);
|
||||
|
||||
return (
|
||||
<Container size="xl" py="md">
|
||||
@@ -254,19 +259,21 @@ export function VesselRegistrationQueuePage() {
|
||||
/>
|
||||
</Group>
|
||||
|
||||
<Card withBorder padding={0}>
|
||||
<AdvancedTable
|
||||
columns={vesselRegistrationQueueColumns({ onStatus: setStatusTarget })}
|
||||
data={items}
|
||||
rowKey={(vessel) => vessel.id}
|
||||
loading={isLoading}
|
||||
onRefresh={refetch}
|
||||
onRowClick={(vessel) => setDetail(vessel)}
|
||||
emptyTitle={
|
||||
search ? 'No vessels match that search.' : 'No vessels registered yet.'
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
<AdvancedTable
|
||||
tableName="Vessel register"
|
||||
columns={vesselRegistrationQueueColumns(showDate, { onStatus: setStatusTarget })}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
isLoading={isLoading}
|
||||
refresh={refetch}
|
||||
onRowClick={(vessel) => setDetail(vessel)}
|
||||
emptyText={
|
||||
search ? 'No vessels match that search.' : 'No vessels registered yet.'
|
||||
}
|
||||
/>
|
||||
|
||||
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
|
||||
<StatusModal
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -6,6 +6,17 @@ export const en = {
|
||||
tagline: 'Control Center',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
validationError: 'Please check your input and try again.',
|
||||
authError: 'Your session has expired. Please sign in again.',
|
||||
permissionError: "You don't have permission to perform this action.",
|
||||
notFoundError: 'The requested item was not found.',
|
||||
fileTooLarge: 'The file is too large to upload.',
|
||||
networkError: 'Network error. Check your connection and try again.',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
@@ -23,6 +34,12 @@ export const en = {
|
||||
typeCombined: 'Combined SA + FF',
|
||||
typeJointInvestment: 'Joint Investment',
|
||||
typeMto: 'Multimodal Transport Operator',
|
||||
typeSEAFARER_REGISTRATION: 'Seafarer Registration',
|
||||
typeVESSEL_OWNERSHIP_TRANSFER: 'Vessel Ownership Transfer',
|
||||
typeCERTIFICATE_OF_COMPETENCY: 'Certificate of Competency',
|
||||
typeCERTIFICATE_OF_PROFICIENCY: 'Certificate of Proficiency',
|
||||
typeENDORSEMENT_COC: 'CoC Endorsement',
|
||||
typeENDORSEMENT_GOC: 'GOC Endorsement',
|
||||
primary: 'Primary',
|
||||
destinations: 'Go to',
|
||||
noResults: 'Nothing found',
|
||||
@@ -43,6 +60,7 @@ export const en = {
|
||||
userManagement: 'User Management',
|
||||
seamanBookQueue: 'Seaman Book Queue',
|
||||
vesselRegistrationHeadDashboard: 'Vessel Registration Head Dashboard',
|
||||
vesselRegistrationApplicationQueue: 'Vessel Registration Queue',
|
||||
vesselRegistrationQueue: 'Vessel Register',
|
||||
vesselFormBuilder: 'Vessel Form Builder',
|
||||
vesselRegistrationReport: 'Vessel Registration Report',
|
||||
@@ -56,13 +74,18 @@ export const en = {
|
||||
waiver: 'Waiver',
|
||||
preWaiverQueue: 'Pre-Waiver Queue',
|
||||
postWaiverQueue: 'Post-Waiver Queue',
|
||||
cocQueue: 'CoC / CoP Queue',
|
||||
endorsementQueue: 'Endorsement Queue',
|
||||
cocQueue: 'CoC Queue',
|
||||
copQueue: 'CoP Queue',
|
||||
endorsementCocQueue: 'CoC Endorsement Queue',
|
||||
endorsementGocQueue: 'GOC Endorsement Queue',
|
||||
vesselRegistrations: 'Vessel Registration',
|
||||
vesselTransfers: 'Vessel Ownership Transfer',
|
||||
seafarerRegistry: 'Seafarer Registry',
|
||||
seafarerRegistrationQueue: 'Seafarer Registration Queue',
|
||||
applications: 'Applications',
|
||||
paymentConfig: 'Payment Config',
|
||||
analytics: 'Analytics',
|
||||
medicalVerification: 'Medical Verification',
|
||||
medicalVerification: 'Medical and Sea Service Verification',
|
||||
locations: 'Locations',
|
||||
configuration: 'Configuration',
|
||||
profile: 'Profile',
|
||||
@@ -86,6 +109,12 @@ export const en = {
|
||||
collapse: 'Collapse',
|
||||
expand: 'Expand',
|
||||
toggleTheme: 'Toggle light / dark mode',
|
||||
refresh: 'Refresh',
|
||||
view: 'View',
|
||||
toggleColumns: 'Toggle columns',
|
||||
noResult: 'No results',
|
||||
switchCalendar: 'Switch calendar type',
|
||||
time: 'Time',
|
||||
},
|
||||
|
||||
breadcrumbs: {
|
||||
@@ -332,6 +361,11 @@ export const en = {
|
||||
'Not enough approved questions in the bank for this subject.',
|
||||
},
|
||||
|
||||
country: {
|
||||
select: 'Select a country',
|
||||
notFound: 'No countries found',
|
||||
},
|
||||
|
||||
location: {
|
||||
title: 'Locations',
|
||||
hierarchy: 'Location Hierarchy',
|
||||
@@ -753,6 +787,23 @@ export const en = {
|
||||
anyType: 'Any',
|
||||
typeCol: 'Type',
|
||||
statusCol: 'Status',
|
||||
statusValues: {
|
||||
DRAFT: 'Draft',
|
||||
SUBMITTED: 'Submitted',
|
||||
UNDER_REVIEW: 'Under Review',
|
||||
UNDER_EVALUATION: 'Under Evaluation',
|
||||
RESUBMIT_REQUIRED: 'Resubmit Required',
|
||||
INSPECTION_PENDING: 'Inspection Pending',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
PAYMENT_PENDING: 'Payment Pending',
|
||||
PAID: 'Paid',
|
||||
PAYMENT_CONFIRMED: 'Preparing Certificate',
|
||||
CERTIFICATE_ISSUED: 'Certificate Issued',
|
||||
COMPLETED: 'Completed',
|
||||
},
|
||||
submittedFrom: 'Submitted from',
|
||||
submittedTo: 'Submitted to',
|
||||
clearFilters: 'Clear',
|
||||
@@ -765,6 +816,7 @@ export const en = {
|
||||
compact: 'Compact',
|
||||
number: 'App #',
|
||||
company: 'Company',
|
||||
applicant: 'Applicant',
|
||||
tin: 'TIN',
|
||||
submitted: 'Submitted',
|
||||
sla: 'Age / SLA',
|
||||
@@ -960,6 +1012,42 @@ export const en = {
|
||||
noFile: 'No file',
|
||||
noFileUploaded: 'Nothing uploaded yet',
|
||||
noInlinePreview: 'This file type cannot be previewed in the browser.',
|
||||
previewFallback: 'document',
|
||||
},
|
||||
inspectionStatus: {
|
||||
SCHEDULED: 'Scheduled',
|
||||
COMPLETED: 'Completed',
|
||||
CANCELLED: 'Cancelled',
|
||||
},
|
||||
kindValues: {
|
||||
NEW: 'New',
|
||||
RENEWAL: 'Renewal',
|
||||
},
|
||||
checklist: {
|
||||
officePremises: 'Office premises',
|
||||
storageFacilities: 'Warehouse / storage facilities',
|
||||
vehiclesEquipment: 'Vehicles / equipment',
|
||||
safetyCompliance: 'Safety & regulatory compliance',
|
||||
},
|
||||
eligibilityRule: {
|
||||
capitalThreshold: 'Paid-up capital ≥ {{amount}}',
|
||||
notRecorded: 'Not recorded',
|
||||
declaredSuffix: ' (declared)',
|
||||
verifiedSuffix: ' (verified)',
|
||||
inspectionCompleted: 'Physical inspection completed',
|
||||
recorded: 'Recorded',
|
||||
notYetRecorded: 'Not yet recorded',
|
||||
},
|
||||
sla: {
|
||||
untracked: 'No turnaround target is set for this licence type.',
|
||||
target: 'Target {{hours}}h from submission ({{date}})',
|
||||
met: 'Met',
|
||||
missed: 'Missed',
|
||||
decidedIn: 'Decided in {{duration}}. {{target}}',
|
||||
overdue: 'Overdue {{duration}}',
|
||||
overdueBy: 'Overdue by {{duration}}. {{target}}',
|
||||
left: '{{duration}} left',
|
||||
remaining: '{{duration}} remaining. {{target}}',
|
||||
},
|
||||
done: {
|
||||
completeReview: 'Review completed',
|
||||
@@ -1031,6 +1119,162 @@ export const en = {
|
||||
noPermission: 'You do not have permission',
|
||||
noPublishPermission: 'You cannot publish designs',
|
||||
},
|
||||
|
||||
seafarerRegistry: {
|
||||
title: 'Seafarer registry',
|
||||
profileCount_one: '{{count}} profile',
|
||||
profileCount_other: '{{count}} profiles',
|
||||
searchPlaceholder: 'Name, ID or seafarer number',
|
||||
emptySearch: 'No profiles match that search.',
|
||||
emptyNone: 'No seafarers registered yet.',
|
||||
columns: {
|
||||
name: 'Name',
|
||||
number: 'Seafarer №',
|
||||
department: 'Department',
|
||||
idNumber: 'ID number',
|
||||
phone: 'Phone',
|
||||
status: 'Status',
|
||||
actions: 'Actions',
|
||||
},
|
||||
notRegistered: 'Not registered',
|
||||
incomplete: 'Incomplete',
|
||||
statusAction: 'Status',
|
||||
statusActionTooltip: 'Suspend / reinstate / close',
|
||||
status: {
|
||||
ACTIVE: 'Active',
|
||||
INACTIVE: 'Inactive',
|
||||
PENDING: 'Pending',
|
||||
SUSPENDED: 'Suspended',
|
||||
},
|
||||
departments: {
|
||||
DECK: 'Deck',
|
||||
ENGINE: 'Engine',
|
||||
CATERING: 'Catering',
|
||||
},
|
||||
recordStatus: {
|
||||
SUBMITTED: 'Submitted',
|
||||
VERIFIED: 'Verified',
|
||||
REJECTED: 'Rejected',
|
||||
},
|
||||
drawer: {
|
||||
seafarerNumber: 'Seafarer number',
|
||||
department: 'Department',
|
||||
status: 'Status',
|
||||
notRegistered: 'NOT REGISTERED',
|
||||
statusReason: 'Status reason: {{reason}}',
|
||||
seaServiceTab: 'Sea Service',
|
||||
medicalTab: 'Medical',
|
||||
noSeaService: 'No sea-service records.',
|
||||
noMedical: 'No medical certificates.',
|
||||
vessel: 'Vessel',
|
||||
rank: 'Rank',
|
||||
period: 'Period',
|
||||
imoPrefix: 'IMO {{number}}',
|
||||
issuer: 'Issuer',
|
||||
validity: 'Validity',
|
||||
fitness: 'Fitness',
|
||||
},
|
||||
modal: {
|
||||
title: 'Change seafarer status',
|
||||
body: '{{number}} — currently {{status}}. The reason is recorded and visible to the seafarer.',
|
||||
newStatus: 'New status',
|
||||
suspend: 'Suspend',
|
||||
close: 'Close',
|
||||
reinstate: 'Reinstate',
|
||||
reason: 'Reason',
|
||||
cancel: 'Cancel',
|
||||
confirm: 'Confirm',
|
||||
updated: 'Seafarer status updated',
|
||||
updateFailed: 'Could not update the status',
|
||||
},
|
||||
},
|
||||
|
||||
paymentConfig: {
|
||||
title: 'Payment configuration',
|
||||
subtitle: 'What each licence costs. Applicants are charged after approval, and the amount is fixed onto the application at that moment.',
|
||||
changeNotice: 'A change applies to applications approved from now on. Anything already approved keeps the amount it was quoted, so an edit here can never alter what an applicant has already been asked to pay.',
|
||||
loadError: 'Could not load licence types',
|
||||
columns: {
|
||||
type: 'Licence type',
|
||||
newApplication: 'New application',
|
||||
renewal: 'Renewal',
|
||||
charged: 'Charged?',
|
||||
},
|
||||
renewalSameTooltip: 'No separate renewal fee — renewal is charged at the new-application rate',
|
||||
renewalSameAsNew: '(same as new)',
|
||||
chargedOnApproval: 'On approval',
|
||||
chargedNotChargedTooltip: 'This licence type ends with an EMA decision and never reaches a payment stage',
|
||||
chargedNotCharged: 'Not charged',
|
||||
edit: 'Edit',
|
||||
gateway: {
|
||||
title: 'Payment gateway',
|
||||
subtitle: 'Set by the payment service environment — not editable here.',
|
||||
provider: 'Telebirr',
|
||||
bypassTooltip: 'ALLOW_PAYMENT_BYPASS is on, so an applicant can settle a fee without paying. It is refused in production.',
|
||||
bypassEnabled: 'Test bypass enabled',
|
||||
bypassOff: 'Test bypass off',
|
||||
},
|
||||
modal: {
|
||||
title: 'Fees — {{type}}',
|
||||
noPaymentStage: 'This licence type concludes with an EMA decision and never reaches a payment stage, so a fee set here stays unused until that changes.',
|
||||
chargeableLabel: 'This licence carries a fee',
|
||||
chargeableDescription: 'Turn off for licence types applicants are never charged for.',
|
||||
newFeeLabel: 'New application fee',
|
||||
sameRateLabel: 'Charge renewal at the same rate',
|
||||
sameRateDescription: 'Turn off to set a separate renewal fee.',
|
||||
renewalFeeLabel: 'Renewal fee',
|
||||
currencyLabel: 'Currency',
|
||||
cancel: 'Cancel',
|
||||
save: 'Save fees',
|
||||
missingNewFee: 'Enter a new-application fee, or turn off "carries a fee".',
|
||||
missingRenewalFee: 'Enter a renewal fee, or charge renewal at the same rate.',
|
||||
updated: '{{type}} fees updated.',
|
||||
saveFailed: 'Could not save the fees',
|
||||
},
|
||||
},
|
||||
|
||||
recordVerification: {
|
||||
title: 'Record verification',
|
||||
subtitle: 'Submitted sea-service records and medical certificates awaiting a ruling. Verified records are frozen; rejections return to the seafarer with your remark.',
|
||||
tabs: {
|
||||
medical: 'Medical ({{count}})',
|
||||
seaService: 'Sea Service ({{count}})',
|
||||
},
|
||||
columns: {
|
||||
seafarer: 'Seafarer',
|
||||
issuer: 'Issuer',
|
||||
validity: 'Validity',
|
||||
fitness: 'Fitness',
|
||||
vessel: 'Vessel',
|
||||
rank: 'Rank',
|
||||
period: 'Period',
|
||||
actions: 'Actions',
|
||||
},
|
||||
fitness: {
|
||||
FIT: 'Fit',
|
||||
FIT_WITH_RESTRICTIONS: 'Fit with restrictions',
|
||||
UNFIT: 'Unfit',
|
||||
},
|
||||
evidence: 'Evidence',
|
||||
verify: 'Verify',
|
||||
reject: 'Reject',
|
||||
evidenceTitleMedical: 'Evidence for {{name}} Medical Certificate',
|
||||
evidenceTitleSeaService: 'Evidence for {{name}} Sea Service Record',
|
||||
certificateVerified: 'Certificate verified',
|
||||
seaServiceVerified: 'Sea-service record verified',
|
||||
certificateRejected: 'Certificate rejected',
|
||||
seaServiceRejected: 'Sea-service record rejected',
|
||||
rulingFailed: 'Could not record the ruling',
|
||||
emptyText: 'Nothing awaiting verification.',
|
||||
rejectMedicalTitle: 'Reject medical certificate',
|
||||
rejectSeaServiceTitle: 'Reject sea-service record',
|
||||
rejectReasonLabel: 'What must the seafarer fix?',
|
||||
cancel: 'Cancel',
|
||||
evidenceModalTitle: 'Evidence Attachments',
|
||||
noAttachments: 'No evidence attachments uploaded for this record.',
|
||||
view: 'View',
|
||||
noUrl: 'No URL',
|
||||
},
|
||||
};
|
||||
|
||||
export type Translations = typeof en;
|
||||
|
||||
@@ -8,7 +8,7 @@ import { AppHeader, AppSidebar } from '@ema-platform/ui';
|
||||
import type { NavItem, NavSection } from '@ema-platform/ui';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { AppTopNav, filterByPermissions } from '@ema-platform/ui';
|
||||
import { useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { baseApi, useGetQueueCountsQuery } from '@ema-platform/api';
|
||||
import { usePermissions } from '@ema-platform/auth';
|
||||
import { SUPPORTED_LANGUAGES } from '../i18n/config';
|
||||
import { useAppDispatch, useAppSelector } from '../store/hooks';
|
||||
@@ -76,12 +76,18 @@ export function BackofficeLayout() {
|
||||
|
||||
const displayName = user?.name?.en || user?.username || '';
|
||||
const initials = displayName
|
||||
? displayName.split(/\s+/).map((s) => s[0]).join('').toUpperCase().slice(0, 2)
|
||||
: '?';
|
||||
? displayName
|
||||
.split(/\s+/)
|
||||
.map((s) => s[0])
|
||||
.join("")
|
||||
.toUpperCase()
|
||||
.slice(0, 2)
|
||||
: "?";
|
||||
|
||||
const handleLogout = useCallback(() => {
|
||||
dispatch(logout());
|
||||
navigate('/login');
|
||||
dispatch(baseApi.util.resetApiState());
|
||||
navigate("/login");
|
||||
}, [dispatch, navigate]);
|
||||
|
||||
const segments = location.pathname.split('/').filter(Boolean);
|
||||
@@ -89,7 +95,7 @@ export function BackofficeLayout() {
|
||||
// readable form of the path segment. Every crumb was previously labelled
|
||||
// "Dashboard", which made the trail useless.
|
||||
const crumbs = [
|
||||
{ label: t('nav.dashboard'), path: '/dashboard' },
|
||||
{ label: t("nav.dashboard"), path: "/dashboard" },
|
||||
...segments
|
||||
.map((_, i) => '/' + segments.slice(0, i + 1).join('/'))
|
||||
.filter((path) => path !== '/dashboard')
|
||||
@@ -123,27 +129,31 @@ export function BackofficeLayout() {
|
||||
setCollapsed((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
const isSidebar = layoutMode === 'sidebar';
|
||||
const isSidebar = layoutMode === "sidebar";
|
||||
|
||||
return (
|
||||
<AppShell
|
||||
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }}
|
||||
navbar={isSidebar ? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: 'sm',
|
||||
collapsed: { mobile: !opened },
|
||||
} : undefined}
|
||||
navbar={
|
||||
isSidebar
|
||||
? {
|
||||
width: collapsed ? 72 : 264,
|
||||
breakpoint: "sm",
|
||||
collapsed: { mobile: !opened },
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
padding="lg"
|
||||
>
|
||||
<AppShell.Header
|
||||
style={{
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderBottom: '1px solid var(--mantine-color-gray-2)',
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
background: "var(--mantine-color-body)",
|
||||
borderBottom: "1px solid var(--mantine-color-gray-2)",
|
||||
display: "flex",
|
||||
flexDirection: "column",
|
||||
}}
|
||||
>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: '0 32px' }}>
|
||||
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}>
|
||||
<AppHeader
|
||||
onToggleNav={toggleNav}
|
||||
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
|
||||
@@ -151,7 +161,7 @@ export function BackofficeLayout() {
|
||||
breadcrumbs={crumbs}
|
||||
onNavigate={navigate}
|
||||
onLogout={handleLogout}
|
||||
userName={displayName || t('app.name')}
|
||||
userName={displayName || t("app.name")}
|
||||
userInitials={initials}
|
||||
supportedLanguages={SUPPORTED_LANGUAGES}
|
||||
/>
|
||||
@@ -183,10 +193,10 @@ export function BackofficeLayout() {
|
||||
<AppShell.Navbar
|
||||
p={0}
|
||||
style={{
|
||||
overflow: 'hidden',
|
||||
transition: 'width 200ms ease',
|
||||
background: 'var(--mantine-color-body)',
|
||||
borderRight: '1px solid var(--mantine-color-gray-2)',
|
||||
overflow: "hidden",
|
||||
transition: "width 200ms ease",
|
||||
background: "var(--mantine-color-body)",
|
||||
borderRight: "1px solid var(--mantine-color-gray-2)",
|
||||
}}
|
||||
>
|
||||
<AppSidebar
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {
|
||||
IconAnchor,
|
||||
IconArrowsExchange,
|
||||
IconBook2,
|
||||
IconChartBar,
|
||||
IconClipboardList,
|
||||
@@ -9,9 +10,9 @@ import {
|
||||
IconGauge,
|
||||
IconGavel,
|
||||
IconHeart,
|
||||
IconId,
|
||||
IconLayoutDashboard,
|
||||
IconListCheck,
|
||||
IconMapPin,
|
||||
IconQuestionMark,
|
||||
IconReport,
|
||||
IconRosetteDiscountCheck,
|
||||
@@ -109,17 +110,21 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupSeafarer',
|
||||
items: [
|
||||
{ to: '/seafarer-registry', label: 'nav.seafarerRegistry', icon: IconUsers },
|
||||
{ to: '/licence-review/type/SEAFARER_REGISTRATION', label: 'nav.seafarerRegistrationQueue', icon: IconId },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_COMPETENCY', label: 'nav.cocQueue', icon: IconShieldCheck },
|
||||
{ to: '/licence-review/type/CERTIFICATE_OF_PROFICIENCY', label: 'nav.copQueue', icon: IconShieldCheck },
|
||||
{ to: '/seaman-book-queue', label: 'nav.seamanBookQueue', icon: IconBook2, soon: true },
|
||||
{ to: '/endorsement-queue', label: 'nav.endorsementQueue', icon: IconRubberStamp, soon: true },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_COC', label: 'nav.endorsementCocQueue', icon: IconRubberStamp },
|
||||
{ to: '/licence-review/type/ENDORSEMENT_GOC', label: 'nav.endorsementGocQueue', icon: IconRubberStamp },
|
||||
{ to: '/medical-verification', label: 'nav.medicalVerification', icon: IconHeart },
|
||||
],
|
||||
},
|
||||
{
|
||||
label: 'nav.groupVessels',
|
||||
items: [
|
||||
{ to: '/licence-review/type/VESSEL_REGISTRATION', label: 'nav.vesselRegistrationApplicationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-registration-queue', label: 'nav.vesselRegistrationQueue', icon: IconAnchor },
|
||||
{ to: '/vessel-ownership-transfer', label: 'nav.ownershipTransferQueue', icon: IconFileDescription, soon: true },
|
||||
{ to: '/licence-review/type/VESSEL_OWNERSHIP_TRANSFER', label: 'nav.ownershipTransferQueue', icon: IconArrowsExchange },
|
||||
{ to: '/vessel-registration-queue/new', label: 'nav.vesselFormBuilder', icon: IconFilePlus, soon: true },
|
||||
{ to: '/vessel-registration-report', label: 'nav.vesselRegistrationReport', icon: IconChartBar, soon: true },
|
||||
{ to: '/vessel-registration-head-dashboard', label: 'nav.vesselRegistrationHeadDashboard', icon: IconGauge, soon: true },
|
||||
@@ -138,7 +143,6 @@ export const NAV_SECTIONS: NavSection[] = [
|
||||
label: 'nav.groupAdministration',
|
||||
items: [
|
||||
{ to: '/um/user-management/dashboard', label: 'nav.userManagement', icon: IconUserShield },
|
||||
{ to: '/locations', label: 'nav.locations', icon: IconMapPin },
|
||||
{
|
||||
to: '/configuration',
|
||||
label: 'nav.configuration',
|
||||
|
||||
@@ -16,11 +16,8 @@ import { DashboardPage } from '../features/dashboard/pages/DashboardPage';
|
||||
import UserManagementPage from '../features/user-management/UserManagementPage';
|
||||
import { ProfilePage } from '../features/profile/pages/ProfilePage';
|
||||
import { ConfigurationPage } from '../features/configuration/pages/ConfigurationPage';
|
||||
import { LocationPage } from '../features/location/pages/LocationPage';
|
||||
import { AnalyticsPage } from '../features/analytics/pages/AnalyticsPage';
|
||||
import { ApplicationReviewPage } from '../features/applications/pages/ApplicationReviewPage';
|
||||
import { EndorsementQueuePage } from '../features/endorsement/pages/EndorsementQueuePage';
|
||||
import { EndorsementReviewPage } from '../features/endorsement/pages/EndorsementReviewPage';
|
||||
import { MedicalVerificationPage } from '../features/medical-verification/pages/MedicalVerificationPage';
|
||||
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
|
||||
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
|
||||
@@ -34,8 +31,6 @@ import { VesselRegistrationQueuePage } from '../features/vessel-registration/pag
|
||||
import { VesselRegistrationReviewPage } from '../features/vessel-registration/pages/VesselRegistrationReviewPage';
|
||||
import { VesselRegistrationReportPage } from '../features/vessel-registration/pages/VesselRegistrationReportPage';
|
||||
import { VesselRegistrationFormBuilderPage } from '../features/vessel-registration/pages/VesselRegistrationFormBuilderPage';
|
||||
import { VesselOwnershipTransferQueuePage } from '../features/vessel-registration/pages/VesselOwnershipTransferQueuePage';
|
||||
import { VesselOwnershipTransferReviewPage } from '../features/vessel-registration/pages/VesselOwnershipTransferReviewPage';
|
||||
import { VesselRegistrationHeadDashboardPage } from '../features/vessel-registration-head/pages/VesselRegistrationHeadDashboardPage';
|
||||
import { LicenseQueuePage } from '../features/license-review/pages/LicenseQueuePage';
|
||||
import { LicenseRegisterPage } from '../features/license-register/pages/LicenseRegisterPage';
|
||||
@@ -69,14 +64,14 @@ const router = createBrowserRouter([
|
||||
{ path: 'logistics-head-dashboard', element: <LogisticsHeadDashboardPage /> },
|
||||
{ path: 'profile', element: <ProfilePage /> },
|
||||
{ path: 'configuration', element: <ConfigurationPage /> },
|
||||
{ path: 'locations', element: <LocationPage /> },
|
||||
{ path: 'analytics', element: <AnalyticsPage /> },
|
||||
{ path: 'applications/:id', element: <ApplicationReviewPage /> },
|
||||
// CoC/CoP review happens in the config-driven licence queue.
|
||||
{ path: 'coc-queue', element: <Navigate to="/licence-review/type/CERTIFICATE_OF_COMPETENCY" replace /> },
|
||||
{ path: 'coc-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'endorsement-queue', element: <EndorsementQueuePage /> },
|
||||
{ path: 'endorsement-queue/:id', element: <EndorsementReviewPage /> },
|
||||
// Endorsement review happens in the config-driven licence queue.
|
||||
{ path: 'endorsement-queue', element: <Navigate to="/licence-review/type/ENDORSEMENT_COC" replace /> },
|
||||
{ path: 'endorsement-queue/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
{ path: 'medical-verification', element: <MedicalVerificationPage /> },
|
||||
{ path: 'payment-config', element: <PaymentConfigPage /> },
|
||||
{ path: 'seafarer-registry', element: <SeafarerRegistryPage /> },
|
||||
@@ -88,10 +83,10 @@ const router = createBrowserRouter([
|
||||
{ path: 'exam-appeals', element: <ExamAppealsPage /> },
|
||||
{ path: 'vessel-registration-queue', element: <VesselRegistrationQueuePage /> },
|
||||
{ path: 'vessel-registration-queue/new', element: <VesselRegistrationFormBuilderPage /> },
|
||||
{ path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <VesselOwnershipTransferQueuePage /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <VesselOwnershipTransferReviewPage /> },
|
||||
// { path: 'vessel-registration-queue/:id', element: <VesselRegistrationReviewPage /> },
|
||||
//{ path: 'vessel-registration-report', element: <VesselRegistrationReportPage /> },
|
||||
{ path: 'vessel-ownership-transfer', element: <Navigate to="/licence-review/type/VESSEL_OWNERSHIP_TRANSFER" replace /> },
|
||||
{ path: 'vessel-ownership-transfer/:id', element: <Navigate to="/licence-review" replace /> },
|
||||
// Config-driven review workspace, shared by every licence type.
|
||||
{ path: 'certificate-designer', element: <CertificateDesignerPage /> },
|
||||
{ path: 'licence-review', element: <LicenseQueuePage /> },
|
||||
|
||||
@@ -7,11 +7,12 @@ import {
|
||||
authStorage,
|
||||
refreshAccessToken,
|
||||
logout,
|
||||
setToken,
|
||||
} from '@ema-platform/auth';
|
||||
import type { AuthUser, CurrentProfile } from '@ema-platform/auth';
|
||||
import { preferencesReducer } from './preferences.slice';
|
||||
|
||||
configureAuthStorage('ema-backoffice');
|
||||
configureAuthStorage('ema-backoffice', true);
|
||||
|
||||
const preloadedAuth = (() => {
|
||||
const token = authStorage.getToken();
|
||||
@@ -36,7 +37,11 @@ export const store = configureStore({
|
||||
});
|
||||
|
||||
configureTokenRefresh({
|
||||
onTokenExpired: refreshAccessToken,
|
||||
onTokenExpired: async () => {
|
||||
const token = await refreshAccessToken();
|
||||
store.dispatch(setToken(token));
|
||||
return token;
|
||||
},
|
||||
onAuthFailure: () => {
|
||||
store.dispatch(logout());
|
||||
window.location.href = '/login';
|
||||
|
||||
@@ -5,6 +5,44 @@
|
||||
|
||||
*, *::before, *::after { box-sizing: border-box; }
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Scrollbars — themed instead of the raw OS default, so a dark page doesn't
|
||||
carry a stark white scrollbar (or vice versa). Firefox via scrollbar-color,
|
||||
Chrome/Safari/Edge via the ::-webkit-scrollbar-* pseudo-elements. Colors
|
||||
come from Mantine's dark palette so they track the active color scheme
|
||||
instead of a fixed gray.
|
||||
--------------------------------------------------------------------------- */
|
||||
* {
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: var(--mantine-color-gray-5) transparent;
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] * {
|
||||
scrollbar-color: var(--mantine-color-dark-3) transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
}
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
border-radius: 8px;
|
||||
border: 2px solid transparent;
|
||||
background-clip: content-box;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb {
|
||||
background-color: var(--mantine-color-dark-3);
|
||||
}
|
||||
[data-mantine-color-scheme='dark'] ::-webkit-scrollbar-thumb:hover {
|
||||
background-color: var(--mantine-color-dark-2);
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------------------
|
||||
Print — the review dossier.
|
||||
|
||||
|
||||
@@ -1,124 +0,0 @@
|
||||
import { useState } from 'react';
|
||||
import { ActionIcon, Button, Popover, TextInput } from '@mantine/core';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
|
||||
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import { EthDateTime } from 'ethiopian-calendar-date-converter';
|
||||
import '@daypicker/react/dist/style.css';
|
||||
|
||||
const EC_MONTHS_AM = [
|
||||
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
|
||||
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜ',
|
||||
];
|
||||
|
||||
function toAmharicDisplay(date: Date): string {
|
||||
try {
|
||||
const eth = EthDateTime.fromEuropeanDate(date);
|
||||
return `${EC_MONTHS_AM[eth.month - 1]} ${eth.date}/${eth.year}`;
|
||||
} catch {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
export function toEthiopicDateLabel(date: Date): string {
|
||||
try {
|
||||
const eth = EthDateTime.fromEuropeanDate(date);
|
||||
return `EC ${eth.year}-${String(eth.month).padStart(2, '0')}-${String(eth.date).padStart(2, '0')}`;
|
||||
} catch {
|
||||
return date.toLocaleDateString('en-US');
|
||||
}
|
||||
}
|
||||
|
||||
export interface AmharicDatePickerProps {
|
||||
label?: string;
|
||||
value?: Date | null;
|
||||
onChange?: (date: Date | null) => void;
|
||||
required?: boolean;
|
||||
placeholder?: string;
|
||||
}
|
||||
|
||||
export function AmharicDatePicker({
|
||||
label,
|
||||
value,
|
||||
onChange,
|
||||
required,
|
||||
placeholder,
|
||||
}: AmharicDatePickerProps) {
|
||||
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>('AMH');
|
||||
const [opened, { close, toggle }] = useDisclosure(false);
|
||||
|
||||
const displayValue = value
|
||||
? calendarType === 'EN'
|
||||
? value.toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
: toAmharicDisplay(value)
|
||||
: '';
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={opened}
|
||||
onChange={close}
|
||||
position="bottom"
|
||||
width="auto"
|
||||
trapFocus
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<TextInput
|
||||
label={label}
|
||||
required={required}
|
||||
value={displayValue}
|
||||
readOnly
|
||||
placeholder={placeholder}
|
||||
onClick={toggle}
|
||||
leftSection={
|
||||
<Button
|
||||
variant="light"
|
||||
size="compact-xs"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
|
||||
}}
|
||||
aria-label="Switch calendar type"
|
||||
>
|
||||
{calendarType}
|
||||
</Button>
|
||||
}
|
||||
leftSectionWidth="calc(4.375rem * var(--mantine-scale))"
|
||||
rightSection={
|
||||
<ActionIcon size="md" variant="transparent" onClick={toggle}>
|
||||
<IconCalendarEvent size={20} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
</Popover.Target>
|
||||
|
||||
<Popover.Dropdown p="md">
|
||||
{calendarType === 'AMH' ? (
|
||||
<EthiopicDayPicker
|
||||
mode="single"
|
||||
selected={value ?? undefined}
|
||||
numerals="latn"
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ?? null);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<GregorianDayPicker
|
||||
mode="single"
|
||||
selected={value ?? undefined}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ?? null);
|
||||
close();
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
}
|
||||
@@ -1,58 +1,56 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconCertificate } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { IssuedLicense } from '@ema-platform/api';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual, IssuedLicense } from '@ema-platform/api';
|
||||
|
||||
export function certificateColumns(handlers: {
|
||||
export function certificateColumns(deps: {
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownload: (license: IssuedLicense) => void;
|
||||
}): AdvancedTableColumn<IssuedLicense>[] {
|
||||
}): AdvancedColumn<IssuedLicense>[] {
|
||||
return [
|
||||
{
|
||||
key: 'certificateNumber',
|
||||
header: 'Certificate №',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
{row.original.certificateNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'type',
|
||||
header: 'Type',
|
||||
render: (license) => license.licenseType?.name?.en,
|
||||
cell: ({ row }) => deps.localized(row.original.licenseType?.name),
|
||||
},
|
||||
{
|
||||
key: 'issued',
|
||||
header: 'Issued',
|
||||
render: (license) => license.issueDate?.slice(0, 10),
|
||||
cell: ({ row }) => deps.showDate(row.original.issueDate),
|
||||
},
|
||||
{
|
||||
key: 'expires',
|
||||
header: 'Expires',
|
||||
render: (license) => license.expiryDate?.slice(0, 10),
|
||||
cell: ({ row }) => deps.showDate(row.original.expiryDate),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (license) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
color={row.original.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'download',
|
||||
header: '',
|
||||
render: (license) => (
|
||||
label: 'Actions',
|
||||
align: 'right',
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => handlers.onDownload(license)}
|
||||
onClick={() => deps.onDownload(row.original)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
@@ -30,7 +31,8 @@ import {
|
||||
useGetMySeaTimeQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import { certificateColumns } from './columns';
|
||||
|
||||
const CERTIFICATE_TYPE_KEYS = [
|
||||
@@ -71,6 +73,9 @@ export function CertificatesPage() {
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses, refetch: refetchLicenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const issuedTable = useServerTable();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
@@ -108,6 +113,8 @@ export function CertificatesPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const pagedIssued = issuedTable.paginate(issued);
|
||||
|
||||
return (
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>My Certificates</Title>
|
||||
@@ -186,7 +193,7 @@ export function CertificatesPage() {
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.licenseType?.name?.en}
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
@@ -216,13 +223,19 @@ export function CertificatesPage() {
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued certificates</Title>
|
||||
<AdvancedTable
|
||||
tableName="Issued certificates"
|
||||
columns={certificateColumns({
|
||||
localized,
|
||||
showDate,
|
||||
onDownload: (license) => download(license.id),
|
||||
})}
|
||||
data={issued}
|
||||
rowKey={(license) => license.id}
|
||||
onRefresh={refetchLicenses}
|
||||
emptyTitle="No certificates issued yet."
|
||||
data={pagedIssued.rows}
|
||||
itemCount={pagedIssued.itemCount}
|
||||
pageIndex={pagedIssued.pageIndex}
|
||||
onPageChange={issuedTable.setPageIndex}
|
||||
pageSize={issuedTable.pageSize}
|
||||
refresh={refetchLicenses}
|
||||
emptyText="No certificates issued yet."
|
||||
/>
|
||||
</Stack>
|
||||
</Stack>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { Badge, Progress, Text } from '@mantine/core';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
@@ -8,46 +8,42 @@ import {
|
||||
} from '@ema-platform/api';
|
||||
import type { LicenseApplication } from '@ema-platform/api';
|
||||
|
||||
export const dashboardApplicationColumns: AdvancedTableColumn<LicenseApplication>[] =
|
||||
export const dashboardApplicationColumns: AdvancedColumn<LicenseApplication>[] =
|
||||
[
|
||||
{
|
||||
key: 'application',
|
||||
header: 'Application',
|
||||
render: (app) => (
|
||||
cell: ({ row }) => (
|
||||
<>
|
||||
<Text size="sm" fw={600}>
|
||||
{app.applicationNumber}
|
||||
{row.original.applicationNumber}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{app.companyName ?? '—'}
|
||||
{row.original.companyName ?? '—'}
|
||||
</Text>
|
||||
</>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'licence',
|
||||
header: 'Licence',
|
||||
render: (app) => (
|
||||
<Text size="sm">{localized(app.licenseType?.name) || '—'}</Text>
|
||||
cell: ({ row }) => (
|
||||
<Text size="sm">{localized(row.original.licenseType?.name) || '—'}</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'status',
|
||||
header: 'Status',
|
||||
render: (app) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
cell: ({ row }) => (
|
||||
<Badge variant="light" color={STATUS_COLORS[row.original.status]}>
|
||||
{STATUS_LABELS[row.original.status]}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'progress',
|
||||
header: 'Progress',
|
||||
width: 180,
|
||||
render: (app) => (
|
||||
size: 180,
|
||||
cell: ({ row }) => (
|
||||
<Progress
|
||||
value={STATUS_PROGRESS[app.status]}
|
||||
color={STATUS_COLORS[app.status]}
|
||||
value={STATUS_PROGRESS[row.original.status]}
|
||||
color={STATUS_COLORS[row.original.status]}
|
||||
size="sm"
|
||||
radius="xl"
|
||||
/>
|
||||
|
||||
@@ -2,16 +2,13 @@ import { useMemo } from 'react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useSelector } from 'react-redux';
|
||||
import {
|
||||
ActionIcon,
|
||||
Alert,
|
||||
Anchor,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Center,
|
||||
Container,
|
||||
Divider,
|
||||
Group,
|
||||
Loader,
|
||||
Paper,
|
||||
@@ -20,7 +17,6 @@ import {
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconAlertTriangle,
|
||||
@@ -28,9 +24,7 @@ import {
|
||||
IconClipboardList,
|
||||
IconClockHour4,
|
||||
IconCreditCard,
|
||||
IconDownload,
|
||||
IconFileText,
|
||||
IconRefresh,
|
||||
IconShieldCheck,
|
||||
} from '@tabler/icons-react';
|
||||
import { ProfileCompletionNudge } from '../../../profile/components/ProfileCompletionNudge';
|
||||
@@ -38,16 +32,14 @@ import {
|
||||
APPLICANT_ACTION_STATUSES,
|
||||
STATUS_COLORS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
localized,
|
||||
useCreateApplicationMutation,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, useServerTable } from '@ema-platform/ui';
|
||||
import type { IssuedLicense, LicenseApplication } from '@ema-platform/api';
|
||||
import { LicenseCatalogue } from '../../../licensing/components/LicenseCatalogue';
|
||||
import { LicenseCard, useRenewLicense } from '../../../licensing/components/LicenseCard';
|
||||
import { dashboardApplicationColumns } from './columns';
|
||||
|
||||
/**
|
||||
@@ -79,14 +71,6 @@ function formatMoney(amount: string | number | null, currency: string): string {
|
||||
return `${value.toLocaleString('en-US')} ${currency}`;
|
||||
}
|
||||
|
||||
function formatDate(value: string): string {
|
||||
return new Date(value).toLocaleDateString('en-GB', {
|
||||
day: 'numeric',
|
||||
month: 'short',
|
||||
year: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
export function DashboardPage() {
|
||||
const navigate = useNavigate();
|
||||
const displayName = useSelector(
|
||||
@@ -98,8 +82,7 @@ export function DashboardPage() {
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const [getCertificateUrl, { isLoading: isDownloading }] =
|
||||
useGetCertificateUrlMutation();
|
||||
const [createApplication, { isLoading: isRenewing }] =
|
||||
useCreateApplicationMutation();
|
||||
const { renewLicense, isRenewing } = useRenewLicense();
|
||||
|
||||
const items = useMemo(() => applications?.items ?? [], [applications]);
|
||||
const heldLicenses = useMemo(() => licenses?.items ?? [], [licenses]);
|
||||
@@ -124,27 +107,6 @@ export function DashboardPage() {
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
}
|
||||
|
||||
/**
|
||||
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||
* application of kind RENEWAL, asking for that licence type's renewal
|
||||
* document set. `previousLicenseId` is what ties it to the certificate being
|
||||
* replaced, and what the API requires.
|
||||
*/
|
||||
async function renewLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'RENEWAL',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||
}
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={400}>
|
||||
@@ -319,7 +281,12 @@ function ActionRequired({
|
||||
navigate: (path: string) => void;
|
||||
}) {
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" bg="orange.0">
|
||||
<Card
|
||||
withBorder
|
||||
radius="md"
|
||||
padding="md"
|
||||
style={{ backgroundColor: 'var(--mantine-color-orange-light)' }}
|
||||
>
|
||||
<Group gap="xs" mb="sm">
|
||||
<ThemeIcon size="sm" radius="xl" color="orange" variant="filled">
|
||||
<IconAlertTriangle size={14} />
|
||||
@@ -332,7 +299,7 @@ function ActionRequired({
|
||||
{applications.map((app) => {
|
||||
const detail = detailFor(app);
|
||||
return (
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder bg="white">
|
||||
<Paper key={app.id} radius="sm" p="sm" withBorder>
|
||||
<Group justify="space-between" wrap="nowrap">
|
||||
<Group gap="sm" wrap="nowrap">
|
||||
<ThemeIcon
|
||||
@@ -489,108 +456,27 @@ function ApplicationTable({
|
||||
navigate: (path: string) => void;
|
||||
onRefresh: () => void;
|
||||
}) {
|
||||
const table = useServerTable();
|
||||
const paged = table.paginate(applications);
|
||||
return (
|
||||
<Card withBorder radius="md" padding={0}>
|
||||
<AdvancedTable
|
||||
columns={dashboardApplicationColumns}
|
||||
data={applications}
|
||||
rowKey={(app) => app.id}
|
||||
verticalSpacing="sm"
|
||||
onRefresh={onRefresh}
|
||||
onRowClick={(app) =>
|
||||
navigate(
|
||||
app.licenseType?.key
|
||||
? `/licensing/${app.licenseType.key}/applications/${app.id}`
|
||||
: '/licensing/applications',
|
||||
)
|
||||
}
|
||||
/>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
<AdvancedTable
|
||||
tableName="My applications"
|
||||
columns={dashboardApplicationColumns}
|
||||
data={paged.rows}
|
||||
itemCount={paged.itemCount}
|
||||
pageIndex={paged.pageIndex}
|
||||
onPageChange={table.setPageIndex}
|
||||
pageSize={table.pageSize}
|
||||
verticalSpacing="sm"
|
||||
refresh={onRefresh}
|
||||
onRowClick={(app) =>
|
||||
navigate(
|
||||
app.licenseType?.key
|
||||
? `/licensing/${app.licenseType.key}/applications/${app.id}`
|
||||
: '/licensing/applications',
|
||||
)
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,20 +1,254 @@
|
||||
import { Container } from '@mantine/core';
|
||||
import { FeatureUnavailable } from '@ema-platform/ui';
|
||||
import {
|
||||
Alert,
|
||||
Badge,
|
||||
Button,
|
||||
Card,
|
||||
Group,
|
||||
List,
|
||||
Loader,
|
||||
Stack,
|
||||
Table,
|
||||
Text,
|
||||
ThemeIcon,
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconCertificate,
|
||||
IconCircleCheck,
|
||||
IconCircleX,
|
||||
IconInfoCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
STATUS_COLORS,
|
||||
STATUS_LABELS,
|
||||
TERMINAL_STATUSES,
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useGetCertificateUrlMutation,
|
||||
useGetMyApplicationsQuery,
|
||||
useGetMyLicensesQuery,
|
||||
} from '@ema-platform/api';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
const ENDORSEMENT_TYPE_KEYS = ['ENDORSEMENT_COC', 'ENDORSEMENT_GOC'];
|
||||
|
||||
function EligibilityItem({ ok, label }: { ok: boolean; label: string }) {
|
||||
return (
|
||||
<List.Item
|
||||
icon={
|
||||
<ThemeIcon
|
||||
color={ok ? 'teal' : 'red'}
|
||||
variant="light"
|
||||
size="sm"
|
||||
radius="xl"
|
||||
>
|
||||
{ok ? <IconCircleCheck size={14} /> : <IconCircleX size={14} />}
|
||||
</ThemeIcon>
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</List.Item>
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Placeholder until this feature has a backend.
|
||||
*
|
||||
* This page previously rendered hardcoded sample records, which were
|
||||
* indistinguishable from real ones.
|
||||
* Endorsements home, mirroring CertificatesPage: eligibility at a glance, the
|
||||
* two application entry points (CoC / GOC), and the seafarer's endorsement
|
||||
* applications and issued endorsements. The wizard itself is the
|
||||
* config-driven licensing flow.
|
||||
*/
|
||||
export function EndorsementPage() {
|
||||
const navigate = useNavigate();
|
||||
const { profile, isLoading: loadingProfile } = useCurrentProfile();
|
||||
const { data: applications, isLoading: loadingApplications } =
|
||||
useGetMyApplicationsQuery();
|
||||
const { data: licenses } = useGetMyLicensesQuery();
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [getCertificateUrl] = useGetCertificateUrlMutation();
|
||||
|
||||
const registered =
|
||||
Boolean(profile?.seafarerNumber) && profile?.seafarerStatus === 'ACTIVE';
|
||||
|
||||
const endorsementApplications = (applications?.items ?? []).filter((app) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(app.licenseType?.key ?? ''),
|
||||
);
|
||||
const inFlight = endorsementApplications.filter(
|
||||
(app) => !TERMINAL_STATUSES.includes(app.status),
|
||||
);
|
||||
const issued = (licenses?.items ?? []).filter((license) =>
|
||||
ENDORSEMENT_TYPE_KEYS.includes(license.licenseType?.key ?? ''),
|
||||
);
|
||||
|
||||
async function download(licenseId: string) {
|
||||
try {
|
||||
const result = await getCertificateUrl(licenseId).unwrap();
|
||||
window.open(result.url, '_blank', 'noopener');
|
||||
} catch (error) {
|
||||
notify.error(extractErrorMessage(error, 'Could not fetch endorsement'));
|
||||
}
|
||||
}
|
||||
|
||||
if (loadingProfile || loadingApplications) {
|
||||
return (
|
||||
<Group justify="center" py="xl">
|
||||
<Loader />
|
||||
</Group>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Container size="lg" py="xl">
|
||||
<FeatureUnavailable
|
||||
title="Endorsements"
|
||||
description="Endorsements are not connected to the backend yet."
|
||||
/>
|
||||
</Container>
|
||||
<Stack maw={860} mx="auto">
|
||||
<Title order={2}>My Endorsements</Title>
|
||||
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Group justify="space-between" align="flex-start">
|
||||
<div>
|
||||
<Text fw={600} mb={6}>
|
||||
Eligibility
|
||||
</Text>
|
||||
<List spacing={4} size="sm">
|
||||
<EligibilityItem
|
||||
ok={registered}
|
||||
label={
|
||||
registered
|
||||
? `Registered seafarer (${profile?.seafarerNumber})`
|
||||
: 'Active seafarer registration required'
|
||||
}
|
||||
/>
|
||||
</List>
|
||||
</div>
|
||||
<Stack gap="xs">
|
||||
<Button
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_COC/apply')}
|
||||
>
|
||||
Endorse a CoC
|
||||
</Button>
|
||||
<Button
|
||||
variant="light"
|
||||
rightSection={<IconArrowRight size={16} />}
|
||||
onClick={() => navigate('/licensing/ENDORSEMENT_GOC/apply')}
|
||||
>
|
||||
Endorse a GOC
|
||||
</Button>
|
||||
</Stack>
|
||||
</Group>
|
||||
{!registered && (
|
||||
<Alert color="orange" mt="md" icon={<IconInfoCircle size={16} />}>
|
||||
Complete your{' '}
|
||||
<Text
|
||||
component="span"
|
||||
c="blue"
|
||||
style={{ cursor: 'pointer' }}
|
||||
onClick={() => navigate('/seafarer-registration')}
|
||||
>
|
||||
seafarer registration
|
||||
</Text>{' '}
|
||||
first — endorsement applications are refused without it.
|
||||
</Alert>
|
||||
)}
|
||||
</Card>
|
||||
|
||||
{inFlight.length > 0 && (
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Applications in progress</Title>
|
||||
{inFlight.map((app) => (
|
||||
<Card key={app.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{app.applicationNumber}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{localized(app.licenseType?.name)}
|
||||
</Text>
|
||||
</div>
|
||||
<Group>
|
||||
<Badge color={STATUS_COLORS[app.status]}>
|
||||
{STATUS_LABELS[app.status]}
|
||||
</Badge>
|
||||
<Button
|
||||
size="compact-sm"
|
||||
variant="light"
|
||||
onClick={() =>
|
||||
navigate(
|
||||
`/licensing/${app.licenseType?.key}/applications/${app.id}`,
|
||||
)
|
||||
}
|
||||
>
|
||||
{app.status === 'DRAFT' || app.status === 'RESUBMIT_REQUIRED'
|
||||
? 'Continue'
|
||||
: 'View'}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Card>
|
||||
))}
|
||||
</Stack>
|
||||
)}
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>Issued endorsements</Title>
|
||||
{issued.length === 0 ? (
|
||||
<Card withBorder radius="md" p="lg">
|
||||
<Text size="sm" c="dimmed" ta="center">
|
||||
No endorsements issued yet.
|
||||
</Text>
|
||||
</Card>
|
||||
) : (
|
||||
<Table.ScrollContainer minWidth={640}>
|
||||
<Table striped>
|
||||
<Table.Thead>
|
||||
<Table.Tr>
|
||||
<Table.Th>Certificate №</Table.Th>
|
||||
<Table.Th>Type</Table.Th>
|
||||
<Table.Th>Issued</Table.Th>
|
||||
<Table.Th>Expires</Table.Th>
|
||||
<Table.Th>Status</Table.Th>
|
||||
<Table.Th />
|
||||
</Table.Tr>
|
||||
</Table.Thead>
|
||||
<Table.Tbody>
|
||||
{issued.map((license) => (
|
||||
<Table.Tr key={license.id}>
|
||||
<Table.Td>
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Table.Td>
|
||||
<Table.Td>{localized(license.licenseType?.name)}</Table.Td>
|
||||
<Table.Td>{showDate(license.issueDate)}</Table.Td>
|
||||
<Table.Td>{showDate(license.expiryDate)}</Table.Td>
|
||||
<Table.Td>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={license.status === 'ACTIVE' ? 'green' : 'red'}
|
||||
>
|
||||
{license.status}
|
||||
</Badge>
|
||||
</Table.Td>
|
||||
<Table.Td>
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="subtle"
|
||||
leftSection={<IconCertificate size={14} />}
|
||||
onClick={() => download(license.id)}
|
||||
>
|
||||
Download
|
||||
</Button>
|
||||
</Table.Td>
|
||||
</Table.Tr>
|
||||
))}
|
||||
</Table.Tbody>
|
||||
</Table>
|
||||
</Table.ScrollContainer>
|
||||
)}
|
||||
</Stack>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { Badge, Button, Text } from '@mantine/core';
|
||||
import { IconFileText, IconGavel } from '@tabler/icons-react';
|
||||
import type { AdvancedTableColumn } from '@ema-platform/ui';
|
||||
import type { AdvancedColumn } from '@ema-platform/ui';
|
||||
import type { Bilingual } from '@ema-platform/api';
|
||||
import type {
|
||||
AttendanceStatus,
|
||||
MyAppeal,
|
||||
@@ -17,71 +18,66 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
|
||||
DISQUALIFIED: 'red',
|
||||
};
|
||||
|
||||
export function registrationColumns(handlers: {
|
||||
export function registrationColumns(deps: {
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
onDownloadSlip: (registration: MyRegistration) => void;
|
||||
}): AdvancedTableColumn<MyRegistration>[] {
|
||||
}): AdvancedColumn<MyRegistration>[] {
|
||||
return [
|
||||
{
|
||||
key: 'admissionNumber',
|
||||
header: 'Admission №',
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Text ff="monospace" size="sm" fw={600}>
|
||||
{registration.admissionNumber}
|
||||
{row.original.admissionNumber}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'examination',
|
||||
header: 'Examination',
|
||||
render: (registration) => registration.exam?.title?.en ?? '—',
|
||||
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
|
||||
},
|
||||
{
|
||||
key: 'date',
|
||||
header: 'Date',
|
||||
render: (registration) => registration.exam?.date?.slice(0, 10),
|
||||
cell: ({ row }) => deps.showDate(row.original.exam?.date),
|
||||
},
|
||||
{
|
||||
key: 'venue',
|
||||
header: 'Venue',
|
||||
render: (registration) => registration.exam?.venue ?? '—',
|
||||
cell: ({ row }) => row.original.exam?.venue ?? '—',
|
||||
},
|
||||
{
|
||||
key: 'attempt',
|
||||
header: 'Attempt',
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={registration.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
color={row.original.kind === 'RETAKE' ? 'orange' : 'blue'}
|
||||
>
|
||||
{registration.kind === 'RETAKE'
|
||||
? `Retake · ${registration.attemptNumber}`
|
||||
{row.original.kind === 'RETAKE'
|
||||
? `Retake · ${row.original.attemptNumber}`
|
||||
: 'First sitting'}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'attendance',
|
||||
header: 'Attendance',
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={ATTENDANCE_COLOR[registration.attendanceStatus] ?? 'gray'}
|
||||
color={ATTENDANCE_COLOR[row.original.attendanceStatus] ?? 'gray'}
|
||||
>
|
||||
{registration.attendanceStatus}
|
||||
{row.original.attendanceStatus}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'slip',
|
||||
header: 'Slip',
|
||||
render: (registration) => (
|
||||
cell: ({ row }) => (
|
||||
<Button
|
||||
size="compact-xs"
|
||||
variant="light"
|
||||
leftSection={<IconFileText size={13} />}
|
||||
onClick={() => handlers.onDownloadSlip(registration)}
|
||||
onClick={() => deps.onDownloadSlip(row.original)}
|
||||
>
|
||||
Slip
|
||||
</Button>
|
||||
@@ -90,47 +86,44 @@ export function registrationColumns(handlers: {
|
||||
];
|
||||
}
|
||||
|
||||
export function resultColumns(handlers: {
|
||||
export function resultColumns(deps: {
|
||||
localized: (value: Bilingual | undefined) => string;
|
||||
showDate: (value: string | null | undefined) => string;
|
||||
appeals: MyAppeal[];
|
||||
onAppeal: (result: MyResult) => void;
|
||||
}): AdvancedTableColumn<MyResult>[] {
|
||||
}): AdvancedColumn<MyResult>[] {
|
||||
return [
|
||||
{
|
||||
key: 'examination',
|
||||
header: 'Examination',
|
||||
render: (result) => result.exam?.title?.en ?? '—',
|
||||
cell: ({ row }) => deps.localized(row.original.exam?.title) || '—',
|
||||
},
|
||||
{
|
||||
key: 'published',
|
||||
header: 'Published',
|
||||
render: (result) => result.publishedAt?.slice(0, 10) ?? '—',
|
||||
cell: ({ row }) => deps.showDate(row.original.publishedAt),
|
||||
},
|
||||
{
|
||||
key: 'score',
|
||||
header: 'Score',
|
||||
render: (result) => (
|
||||
cell: ({ row }) => (
|
||||
<Text fw={600} size="sm">
|
||||
{result.totalScore}
|
||||
{row.original.totalScore}
|
||||
</Text>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'outcome',
|
||||
header: 'Outcome',
|
||||
render: (result) => (
|
||||
cell: ({ row }) => (
|
||||
<Badge
|
||||
variant="light"
|
||||
color={result.status === 'PASSED' ? 'teal' : 'red'}
|
||||
color={row.original.status === 'PASSED' ? 'teal' : 'red'}
|
||||
>
|
||||
{result.status}
|
||||
{row.original.status}
|
||||
</Badge>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'appeal',
|
||||
header: 'Appeal',
|
||||
render: (result) => {
|
||||
const appeal = handlers.appeals.find((a) => a.resultId === result.id);
|
||||
cell: ({ row }) => {
|
||||
const appeal = deps.appeals.find((a) => a.resultId === row.original.id);
|
||||
return appeal ? (
|
||||
<Badge size="sm" variant="light" color="grape">
|
||||
{appeal.appealNumber} · {appeal.status}
|
||||
@@ -141,7 +134,7 @@ export function resultColumns(handlers: {
|
||||
variant="light"
|
||||
color="grape"
|
||||
leftSection={<IconGavel size={13} />}
|
||||
onClick={() => handlers.onAppeal(result)}
|
||||
onClick={() => deps.onAppeal(row.original)}
|
||||
>
|
||||
Appeal
|
||||
</Button>
|
||||
|
||||
@@ -12,12 +12,14 @@ import {
|
||||
Title,
|
||||
} from '@mantine/core';
|
||||
import { IconClipboardList } from '@tabler/icons-react';
|
||||
import { AdvancedTable, notify } from '@ema-platform/ui';
|
||||
import { AdvancedTable, notify, useServerTable } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
import {
|
||||
useApiQuery,
|
||||
useApiMutation,
|
||||
extractErrorMessage,
|
||||
openAuthedDocument,
|
||||
useLocalized,
|
||||
} from '@ema-platform/api';
|
||||
import { registrationColumns, resultColumns } from './columns';
|
||||
|
||||
@@ -72,6 +74,8 @@ export interface MyAppeal {
|
||||
* when a mark looks wrong.
|
||||
*/
|
||||
export function ExamsPage() {
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
const [appealFor, setAppealFor] = useState<MyResult | null>(null);
|
||||
const [appealReason, setAppealReason] = useState('');
|
||||
|
||||
@@ -101,6 +105,8 @@ export function ExamsPage() {
|
||||
} = useApiQuery<MyAppeal[]>({ url: '/results/appeals/mine', method: 'GET' });
|
||||
const [registerTrigger, { isLoading: registering }] = useApiMutation();
|
||||
const [appealTrigger, { isLoading: appealing }] = useApiMutation();
|
||||
const registrationTable = useServerTable();
|
||||
const resultTable = useServerTable();
|
||||
|
||||
const registeredExamIds = new Set((mine ?? []).map((r) => r.exam?.id));
|
||||
|
||||
@@ -173,6 +179,9 @@ export function ExamsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
const pagedRegistrations = registrationTable.paginate(mine ?? []);
|
||||
const pagedResults = resultTable.paginate(results ?? []);
|
||||
|
||||
return (
|
||||
<Stack maw={900} mx="auto">
|
||||
<Title order={2}>Examinations</Title>
|
||||
@@ -190,10 +199,10 @@ export function ExamsPage() {
|
||||
<Card key={exam.id} withBorder radius="md" p="md">
|
||||
<Group justify="space-between">
|
||||
<div>
|
||||
<Text fw={600}>{exam.title?.en}</Text>
|
||||
<Text fw={600}>{localized(exam.title)}</Text>
|
||||
<Text size="xs" c="dimmed">
|
||||
{exam.certification?.name?.en ?? ''} ·{' '}
|
||||
{exam.date?.slice(0, 10)}
|
||||
{localized(exam.certification?.name)} ·{' '}
|
||||
{showDate(exam.date)}
|
||||
{exam.venue ? ` · ${exam.venue}` : ''}
|
||||
</Text>
|
||||
</div>
|
||||
@@ -219,29 +228,40 @@ export function ExamsPage() {
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My registrations</Title>
|
||||
<AdvancedTable
|
||||
columns={registrationColumns({ onDownloadSlip: downloadSlip })}
|
||||
data={mine ?? []}
|
||||
rowKey={(registration) => registration.id}
|
||||
minWidth={720}
|
||||
onRefresh={refetch}
|
||||
emptyTitle="No exam registrations yet."
|
||||
<AdvancedTable<MyRegistration>
|
||||
tableName="My registrations"
|
||||
columns={registrationColumns({
|
||||
localized,
|
||||
showDate,
|
||||
onDownloadSlip: downloadSlip,
|
||||
})}
|
||||
data={pagedRegistrations.rows}
|
||||
itemCount={pagedRegistrations.itemCount}
|
||||
pageIndex={pagedRegistrations.pageIndex}
|
||||
onPageChange={registrationTable.setPageIndex}
|
||||
pageSize={registrationTable.pageSize}
|
||||
refresh={refetch}
|
||||
emptyText="No exam registrations yet."
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
<Stack gap="xs">
|
||||
<Title order={4}>My results</Title>
|
||||
<AdvancedTable
|
||||
<AdvancedTable<MyResult>
|
||||
tableName="My results"
|
||||
columns={resultColumns({
|
||||
localized,
|
||||
showDate,
|
||||
appeals: appeals ?? [],
|
||||
onAppeal: setAppealFor,
|
||||
})}
|
||||
data={results ?? []}
|
||||
rowKey={(result) => result.id}
|
||||
minWidth={720}
|
||||
onRefresh={refetchResults}
|
||||
emptyTitle="No results have been published yet."
|
||||
emptyDescription="Marks appear here once the authority approves and publishes them."
|
||||
data={pagedResults.rows}
|
||||
itemCount={pagedResults.itemCount}
|
||||
pageIndex={pagedResults.pageIndex}
|
||||
onPageChange={resultTable.setPageIndex}
|
||||
pageSize={resultTable.pageSize}
|
||||
refresh={refetchResults}
|
||||
emptyText="No results have been published yet. Marks appear here once the authority approves and publishes them."
|
||||
/>
|
||||
</Stack>
|
||||
|
||||
@@ -254,7 +274,7 @@ export function ExamsPage() {
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">
|
||||
Explain what you believe went wrong with the marking or the
|
||||
administration of {appealFor?.exam?.title?.en ?? 'this examination'}.
|
||||
administration of {localized(appealFor?.exam?.title) || 'this examination'}.
|
||||
Appeals must be lodged within 14 days of publication.
|
||||
</Text>
|
||||
<Textarea
|
||||
|
||||
@@ -8,9 +8,12 @@ import {
|
||||
} from '@mantine/core';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
type FormFieldConfig,
|
||||
type FormSectionConfig,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { AmharicDatePicker, CountrySelect } from '@ema-platform/ui';
|
||||
|
||||
interface Props {
|
||||
section: FormSectionConfig;
|
||||
@@ -20,6 +23,60 @@ interface Props {
|
||||
disabled?: boolean;
|
||||
/** Keyed `${sectionKey}.${fieldKey}` — shown under the offending field. */
|
||||
errors?: Record<string, string>;
|
||||
/** The applicant's registered vessels, for the vessel-picker field. */
|
||||
vessels?: Vessel[];
|
||||
/**
|
||||
* Fired when the vessel picker's value resolves to a known vessel. Vessel
|
||||
* Information and Current Ownership are separate form sections, so this
|
||||
* component (one instance per section) can only fill its own fields —
|
||||
* the parent uses this to fill the rest via the same `VESSEL_FIELD_FILLERS`.
|
||||
*/
|
||||
onVesselSelected?: (vessel: Vessel) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Maps a Vessel's own fields onto whichever fields the backend put in the
|
||||
* license form — same dual key/label matching the nationality prefill in
|
||||
* LicenseApplicationPage uses, so this doesn't depend on exact field-key
|
||||
* naming in the seeded formSchema. Exported so the parent page can apply it
|
||||
* across every section, not just the one the vessel picker lives in.
|
||||
*/
|
||||
export const VESSEL_FIELD_FILLERS: {
|
||||
matches: (label: string, key: string) => boolean;
|
||||
value: (vessel: Vessel) => unknown;
|
||||
}[] = [
|
||||
{ matches: (l, k) => k === 'registrationNumber' || l.includes('registration number'), value: (v) => v.registrationNumber },
|
||||
{ matches: (l, k) => k === 'vesselName' || l.includes('vessel name'), value: (v) => v.name },
|
||||
{ matches: (l, k) => k === 'category' || k === 'vesselCategory' || l.includes('vessel category'), value: (v) => v.category },
|
||||
{ matches: (l, k) => k === 'vesselType' || l.includes('vessel type'), value: (v) => v.vesselType },
|
||||
{ matches: (l, k) => k === 'imoNumber' || l.includes('imo'), value: (v) => v.imoNumber },
|
||||
{ matches: (l, k) => k === 'hullNumber' || l.includes('hull number'), value: (v) => v.hullNumber },
|
||||
{ matches: (l, k) => k === 'flagState' || l.includes('flag state') || l.includes('flag'), value: (v) => v.flagState },
|
||||
{ matches: (l, k) => k === 'portOfRegistry' || l.includes('port of registry'), value: (v) => v.portOfRegistry },
|
||||
{ matches: (l, k) => k === 'grossTonnage' || l.includes('gross tonnage'), value: (v) => v.grossTonnage },
|
||||
{ matches: (l, k) => k === 'passengerCapacity' || l.includes('passenger capacity'), value: (v) => v.passengerCapacity },
|
||||
{ matches: (l, k) => k === 'lengthMeters' || l.includes('length'), value: (v) => v.lengthMeters },
|
||||
{ matches: (l, k) => k === 'yearBuilt' || l.includes('year built'), value: (v) => v.yearBuilt },
|
||||
{ matches: (l, k) => k === 'engineType' || l.includes('engine type'), value: (v) => v.engineType },
|
||||
{ matches: (l, k) => k === 'enginePowerKw' || l.includes('engine power'), value: (v) => v.enginePowerKw },
|
||||
{ matches: (l, k) => k === 'numberOfEngines' || l.includes('number of engines'), value: (v) => v.numberOfEngines },
|
||||
{ matches: (l, k) => k === 'hullMaterial' || l.includes('hull material'), value: (v) => v.hullMaterial },
|
||||
// Current Ownership: only the "current" owner, never "new owner" —
|
||||
// that's who the vessel is being transferred to, not on file anywhere.
|
||||
{ matches: (l, k) => k === 'currentOwnerName' || k === 'currentOwner' || l.includes('current owner'), value: (v) => v.ownerName },
|
||||
];
|
||||
|
||||
export function fillFromVessel(
|
||||
vessel: Vessel,
|
||||
fields: FormFieldConfig[],
|
||||
onChange: (key: string, value: unknown) => void,
|
||||
) {
|
||||
for (const f of fields) {
|
||||
// English-pinned: matched against the English strings in VESSEL_FIELD_FILLERS.
|
||||
const label = (f.label.en ?? '').toLowerCase();
|
||||
const filler = VESSEL_FIELD_FILLERS.find((m) => m.matches(label, f.key));
|
||||
if (filler) onChange(f.key, filler.value(vessel) ?? '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -36,7 +93,10 @@ export function ConfigDrivenSection({
|
||||
onChange,
|
||||
disabled,
|
||||
errors = {},
|
||||
vessels = [],
|
||||
onVesselSelected,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const fields = [...(section.fields ?? [])].sort(
|
||||
(a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0),
|
||||
);
|
||||
@@ -47,6 +107,8 @@ export function ConfigDrivenSection({
|
||||
if (!conditionHolds(field.showWhen, formData)) return null;
|
||||
|
||||
const label = localized(field.label);
|
||||
// English-pinned: the picker overrides below match English labels.
|
||||
const labelEn = (field.label.en ?? '').toLowerCase();
|
||||
const value = values?.[field.key];
|
||||
const error = errors[`${section.key}.${field.key}`];
|
||||
const common = {
|
||||
@@ -58,10 +120,41 @@ export function ConfigDrivenSection({
|
||||
disabled: disabled || field.readOnly,
|
||||
};
|
||||
const span = field.type === 'TEXTAREA' ? 12 : 6;
|
||||
// Same field the profile Address tab collects — give it the same
|
||||
// searchable, flag-labeled picker instead of a plain option list.
|
||||
const isNationality = field.key === 'nationality' || labelEn.includes('nationality');
|
||||
// Options here can't be seeded statically — they're the applicant's
|
||||
// own vessel register, so this overrides whatever type the backend
|
||||
// configured, the same way nationality overrides SELECT above.
|
||||
const isVesselPicker = field.key === 'vesselId' || field.key === 'vessel' || /^(select\s+)?vessel$/i.test(labelEn.trim());
|
||||
|
||||
return (
|
||||
<Grid.Col span={{ base: 12, md: span }} key={field.key}>
|
||||
{field.type === 'SELECT' ? (
|
||||
{isNationality ? (
|
||||
<CountrySelect
|
||||
{...common}
|
||||
demonym
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : isVesselPicker ? (
|
||||
<Select
|
||||
{...common}
|
||||
placeholder="Select a registered vessel"
|
||||
data={vessels.map((v) => ({ value: v.id, label: `${v.name} — ${v.registrationNumber}` }))}
|
||||
value={(value as string) ?? null}
|
||||
onChange={(v) => {
|
||||
onChange(field.key, v);
|
||||
const vessel = vessels.find((x) => x.id === v);
|
||||
if (vessel) {
|
||||
fillFromVessel(vessel, fields, onChange);
|
||||
onVesselSelected?.(vessel);
|
||||
}
|
||||
}}
|
||||
searchable
|
||||
clearable={!field.required}
|
||||
/>
|
||||
) : field.type === 'SELECT' ? (
|
||||
<Select
|
||||
{...common}
|
||||
data={(field.options ?? []).map((o) => ({
|
||||
@@ -93,11 +186,16 @@ export function ConfigDrivenSection({
|
||||
thousandSeparator={field.type === 'MONEY' ? ',' : undefined}
|
||||
/>
|
||||
) : field.type === 'DATE' ? (
|
||||
<TextInput
|
||||
{...common}
|
||||
type="date"
|
||||
// AmharicDatePicker has no `description`/`withAsterisk` props (from
|
||||
// `common`) — pass `required` explicitly so the asterisk still shows.
|
||||
<AmharicDatePicker
|
||||
label={label}
|
||||
error={error}
|
||||
disabled={common.disabled}
|
||||
required={field.required}
|
||||
dateFormat="date"
|
||||
value={(value as string) ?? ''}
|
||||
onChange={(e) => onChange(field.key, e.currentTarget.value)}
|
||||
onChange={(v) => onChange(field.key, v)}
|
||||
/>
|
||||
) : field.type === 'TEXTAREA' ? (
|
||||
<Textarea
|
||||
|
||||
@@ -19,12 +19,14 @@ import {
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
conditionHolds,
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
type Attachment,
|
||||
type DocumentRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface Props {
|
||||
requirements: DocumentRequirement[];
|
||||
attachments: Attachment[];
|
||||
@@ -56,6 +58,7 @@ export function DocumentSlots({
|
||||
onUploaded,
|
||||
readOnly,
|
||||
}: Props) {
|
||||
const localized = useLocalized();
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const resetRefs = useRef<Record<string, () => void>>({});
|
||||
@@ -63,11 +66,17 @@ export function DocumentSlots({
|
||||
const required = requirements.filter(
|
||||
(r) =>
|
||||
r.mode === 'ALWAYS' ||
|
||||
r.mode === 'OPTIONAL' ||
|
||||
(r.mode === 'CONDITIONAL' && conditionHolds(r.conditionExpression, formData)),
|
||||
);
|
||||
|
||||
async function handle(documentKey: string, file: File | null) {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
setError(`File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`);
|
||||
resetRefs.current[documentKey]?.();
|
||||
return;
|
||||
}
|
||||
setBusy(documentKey);
|
||||
setError(null);
|
||||
const result = await uploadDocument({ ownerType, ownerId, documentKey, file });
|
||||
@@ -92,7 +101,7 @@ export function DocumentSlots({
|
||||
|
||||
return (
|
||||
<Card
|
||||
key={requirement.key}
|
||||
key={requirement.id}
|
||||
withBorder
|
||||
padding="md"
|
||||
style={{
|
||||
@@ -115,6 +124,11 @@ export function DocumentSlots({
|
||||
conditional
|
||||
</Badge>
|
||||
)}
|
||||
{requirement.mode === 'OPTIONAL' && (
|
||||
<Badge size="xs" variant="light" color="gray">
|
||||
optional
|
||||
</Badge>
|
||||
)}
|
||||
{uploaded && !flagRemark && (
|
||||
<Badge size="xs" color="teal" leftSection={<IconCheck size={10} />}>
|
||||
uploaded
|
||||
|
||||
@@ -0,0 +1,147 @@
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import {
|
||||
ActionIcon,
|
||||
Badge,
|
||||
Box,
|
||||
Button,
|
||||
Card,
|
||||
Divider,
|
||||
Group,
|
||||
Text,
|
||||
Tooltip,
|
||||
} from '@mantine/core';
|
||||
import { IconDownload, IconRefresh } from '@tabler/icons-react';
|
||||
import {
|
||||
extractErrorMessage,
|
||||
useLocalized,
|
||||
useCreateApplicationMutation,
|
||||
type IssuedLicense,
|
||||
} from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { useDateDisplayer } from '@ema-platform/shared';
|
||||
|
||||
/**
|
||||
* Renewal reuses the ordinary application wizard — a renewal is an
|
||||
* application of kind RENEWAL, asking for that licence type's renewal
|
||||
* document set. `previousLicenseId` is what ties it to the certificate being
|
||||
* replaced, and what the API requires.
|
||||
*
|
||||
* Shared by the dashboard and My Applications so both offer the same renew
|
||||
* action instead of drifting.
|
||||
*/
|
||||
export function useRenewLicense() {
|
||||
const navigate = useNavigate();
|
||||
const [createApplication, { isLoading: isRenewing }] =
|
||||
useCreateApplicationMutation();
|
||||
|
||||
async function renewLicense(license: IssuedLicense) {
|
||||
const typeKey = license.licenseType?.key;
|
||||
if (!typeKey) return;
|
||||
try {
|
||||
const application = await createApplication({
|
||||
licenseType: typeKey,
|
||||
kind: 'RENEWAL',
|
||||
previousLicenseId: license.id,
|
||||
}).unwrap();
|
||||
navigate(`/licensing/${typeKey}/applications/${application.id}`);
|
||||
} catch (err) {
|
||||
notify.error(extractErrorMessage(err), 'Could not start the renewal');
|
||||
}
|
||||
}
|
||||
|
||||
return { renewLicense, isRenewing };
|
||||
}
|
||||
|
||||
function daysUntil(date: string): number {
|
||||
const ms = new Date(date).getTime() - Date.now();
|
||||
return Math.ceil(ms / 86_400_000);
|
||||
}
|
||||
|
||||
export function LicenseCard({
|
||||
license,
|
||||
isDownloading,
|
||||
isRenewing,
|
||||
onDownload,
|
||||
onRenew,
|
||||
}: {
|
||||
license: IssuedLicense;
|
||||
isDownloading: boolean;
|
||||
isRenewing: boolean;
|
||||
onDownload: () => void;
|
||||
onRenew: () => void;
|
||||
}) {
|
||||
// The API computes both in the authority's timezone; the local fallbacks are
|
||||
// only for a cached response from before those fields existed.
|
||||
const days = license.daysUntilExpiry ?? daysUntil(license.expiryDate);
|
||||
const expired = license.status === 'EXPIRED' || days < 0;
|
||||
const renewable = license.renewable ?? false;
|
||||
const showDate = useDateDisplayer();
|
||||
const localized = useLocalized();
|
||||
|
||||
return (
|
||||
<Card withBorder radius="md" padding="md" className="ema-hover-lift">
|
||||
<Group justify="space-between" align="flex-start" wrap="nowrap">
|
||||
<Box>
|
||||
<Text size="sm" fw={600}>
|
||||
{localized(license.licenseType?.name) || 'Licence'}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mt={2}>
|
||||
{license.certificateNumber}
|
||||
</Text>
|
||||
</Box>
|
||||
<Badge
|
||||
size="sm"
|
||||
variant="light"
|
||||
color={expired ? 'red' : license.status === 'ACTIVE' ? 'teal' : 'gray'}
|
||||
>
|
||||
{expired ? 'Expired' : license.status}
|
||||
</Badge>
|
||||
</Group>
|
||||
|
||||
<Divider my="sm" />
|
||||
|
||||
<Group justify="space-between" align="center">
|
||||
<Box>
|
||||
<Text size="xs" c="dimmed">
|
||||
{expired ? 'Expired on' : 'Valid until'}
|
||||
</Text>
|
||||
<Text size="sm" fw={500}>
|
||||
{showDate(license.expiryDate)}
|
||||
</Text>
|
||||
</Box>
|
||||
<Tooltip label="Download certificate">
|
||||
<ActionIcon
|
||||
variant="light"
|
||||
radius="md"
|
||||
size="lg"
|
||||
loading={isDownloading}
|
||||
onClick={onDownload}
|
||||
>
|
||||
<IconDownload size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Group>
|
||||
|
||||
{/* Renewal opens inside the licence type's window and stays open after
|
||||
expiry, so a lapsed licence is renewed rather than applied for afresh. */}
|
||||
{renewable && (
|
||||
<Button
|
||||
fullWidth
|
||||
mt="sm"
|
||||
size="xs"
|
||||
variant={expired ? 'filled' : 'light'}
|
||||
color={expired ? 'orange' : undefined}
|
||||
loading={isRenewing}
|
||||
leftSection={<IconRefresh size={14} />}
|
||||
onClick={onRenew}
|
||||
>
|
||||
{expired
|
||||
? 'Renew — this licence has expired'
|
||||
: `Renew — expires in ${days} day${days === 1 ? '' : 's'}`}
|
||||
</Button>
|
||||
)}
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
export default LicenseCard;
|
||||
@@ -23,7 +23,7 @@ import {
|
||||
IconTrendingUp,
|
||||
} from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
useGetLicenseCategoriesQuery,
|
||||
useGetLicenseTypesQuery,
|
||||
useGetMyOperatorTypesQuery,
|
||||
@@ -55,6 +55,7 @@ function formatFee(amount: string | number | null, currency: string): string {
|
||||
|
||||
export function LicenseCatalogue() {
|
||||
const navigate = useNavigate();
|
||||
const localized = useLocalized();
|
||||
const { data: types } = useGetLicenseTypesQuery();
|
||||
const { data: categories } = useGetLicenseCategoriesQuery();
|
||||
const { data: operatorTypes, isLoading: loadingOperatorTypes } =
|
||||
@@ -253,6 +254,7 @@ function LicenseTypeCard({
|
||||
canApply: boolean;
|
||||
onSelect: (type: LicenseType) => void;
|
||||
}) {
|
||||
const localized = useLocalized();
|
||||
const capital = type.capitalThreshold ? Number(type.capitalThreshold) : null;
|
||||
|
||||
return (
|
||||
|
||||
@@ -1,13 +1,16 @@
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Badge, Button, FileButton, Group, Loader } from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { IconCheck } from '@tabler/icons-react';
|
||||
import {
|
||||
localized,
|
||||
useLocalized,
|
||||
uploadDocument,
|
||||
useGetAttachmentsQuery,
|
||||
type StaffEvidenceRequirement,
|
||||
} from '@ema-platform/api';
|
||||
|
||||
const MAX_FILE_SIZE_BYTES = 5 * 1024 * 1024;
|
||||
|
||||
interface Props {
|
||||
staffId: string;
|
||||
evidence: StaffEvidenceRequirement[];
|
||||
@@ -27,6 +30,7 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
ownerId: staffId,
|
||||
});
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const localized = useLocalized();
|
||||
|
||||
if (!evidence?.length) return null;
|
||||
|
||||
@@ -42,6 +46,13 @@ export function StaffEvidence({ staffId, evidence, readOnly, onUploaded }: Props
|
||||
accept="application/pdf,image/jpeg,image/png"
|
||||
onChange={async (file) => {
|
||||
if (!file) return;
|
||||
if (file.size > MAX_FILE_SIZE_BYTES) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
message: `File exceeds 5MB limit (${(file.size / 1024 / 1024).toFixed(1)}MB).`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
setBusy(item.docKey);
|
||||
await uploadDocument({
|
||||
ownerType: 'APPLICATION_STAFF',
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import { Center, Loader } from '@mantine/core';
|
||||
import { Navigate, useParams } from 'react-router-dom';
|
||||
import { useGetApplicationQuery } from '@ema-platform/api';
|
||||
|
||||
/**
|
||||
* Resolves `/applications/:applicationId` deep links (notification
|
||||
* callbackUrl, emails) to the canonical `/licensing/:typeCode/applications/:id`
|
||||
* route, which needs the licence type key to load its config.
|
||||
*/
|
||||
export function ApplicationRedirectPage() {
|
||||
const { applicationId } = useParams();
|
||||
const { data, isLoading, isError } = useGetApplicationQuery(applicationId!, {
|
||||
skip: !applicationId,
|
||||
});
|
||||
|
||||
if (!applicationId || isError) {
|
||||
return <Navigate replace to="/licensing/applications" />;
|
||||
}
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Center h={300}>
|
||||
<Loader />
|
||||
</Center>
|
||||
);
|
||||
}
|
||||
|
||||
const typeCode = data?.application.licenseType?.key ?? 'FREIGHT_FORWARDER';
|
||||
return <Navigate replace to={`/licensing/${typeCode}/applications/${applicationId}`} />;
|
||||
}
|
||||
|
||||
export default ApplicationRedirectPage;
|
||||
@@ -29,27 +29,33 @@ import {
|
||||
IconTrash,
|
||||
} from '@tabler/icons-react';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
buildWizardSteps,
|
||||
conditionHolds,
|
||||
extractErrorMessage,
|
||||
extractValidationIssues,
|
||||
localized,
|
||||
useLocalized,
|
||||
validateSections,
|
||||
useAddStaffMutation,
|
||||
useCreateApplicationMutation,
|
||||
useGetApplicationQuery,
|
||||
useGetAttachmentsQuery,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useGetMyVesselsQuery,
|
||||
usePatchSectionMutation,
|
||||
useRemoveStaffMutation,
|
||||
useResolveRemarkMutation,
|
||||
useResubmitApplicationMutation,
|
||||
useSubmitApplicationMutation,
|
||||
type FieldErrors,
|
||||
type FormFieldConfig,
|
||||
type ValidationIssue,
|
||||
type Vessel,
|
||||
} from '@ema-platform/api';
|
||||
import { ConfigDrivenSection } from '../components/ConfigDrivenSection';
|
||||
import { getCountryCode, getCountryName, ModalFooter } from '@ema-platform/ui';
|
||||
import { useCurrentProfile } from '@ema-platform/auth';
|
||||
import { ConfigDrivenSection, fillFromVessel } from '../components/ConfigDrivenSection';
|
||||
import { DocumentSlots } from '../components/DocumentSlots';
|
||||
import { StaffEvidence } from '../components/StaffEvidence';
|
||||
|
||||
@@ -61,9 +67,15 @@ import { StaffEvidence } from '../components/StaffEvidence';
|
||||
export function LicenseApplicationPage() {
|
||||
const { typeCode = 'FREIGHT_FORWARDER', applicationId } = useParams();
|
||||
const navigate = useNavigate();
|
||||
const { i18n } = useTranslation();
|
||||
const localized = useLocalized();
|
||||
|
||||
const { data: config, isLoading: loadingConfig } =
|
||||
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
|
||||
const { profile } = useCurrentProfile();
|
||||
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
|
||||
// here rather than deeper down since it's the shared source of draft state.
|
||||
const { data: vessels } = useGetMyVesselsQuery();
|
||||
const [createApplication] = useCreateApplicationMutation();
|
||||
const [appId, setAppId] = useState<string | undefined>(applicationId);
|
||||
|
||||
@@ -111,6 +123,55 @@ export function LicenseApplicationPage() {
|
||||
if (detail?.application?.formData) setDraft(detail.application.formData);
|
||||
}, [detail?.application?.id, detail?.application?.adjustmentRound]);
|
||||
|
||||
// Nationality and National ID (Fayda) number are already on file from the
|
||||
// profile's Address tab — carry them into whichever section the form
|
||||
// config puts those fields in, rather than asking again. Only fills a
|
||||
// blank; a value already on the draft (the applicant's own edit, or one
|
||||
// the server saved) is left alone.
|
||||
useEffect(() => {
|
||||
const address = profile?.address;
|
||||
if (!address || !config) return;
|
||||
|
||||
setDraft((prev) => {
|
||||
let next = prev;
|
||||
const fill = (
|
||||
matchField: (label: string, key: string) => boolean,
|
||||
toFieldValue: (field: FormFieldConfig) => unknown,
|
||||
) => {
|
||||
for (const section of config.licenseType.formSchema.sections) {
|
||||
// English-pinned: matched against English substrings below ('nationality', 'fayda').
|
||||
const field = section.fields.find((f) => matchField((f.label.en ?? '').toLowerCase(), f.key));
|
||||
if (!field) continue;
|
||||
if (next[section.key]?.[field.key]) return; // already set — leave it
|
||||
next = { ...next, [section.key]: { ...next[section.key], [field.key]: toFieldValue(field) } };
|
||||
return;
|
||||
}
|
||||
};
|
||||
|
||||
if (address.nationality) {
|
||||
// Profile stores the full country name; the field always renders as
|
||||
// a CountrySelect (see ConfigDrivenSection), which takes alpha-2
|
||||
// codes regardless of the backend's configured field type.
|
||||
fill(
|
||||
(label, key) => key === 'nationality' || label.includes('nationality'),
|
||||
() => getCountryCode(address.nationality) ?? address.nationality,
|
||||
);
|
||||
}
|
||||
if (address.idType === 'NID' && address.idNumber) {
|
||||
fill(
|
||||
(label, key) =>
|
||||
key === 'idNumber' || key === 'nationalId' || key === 'faydaNumber' ||
|
||||
label.includes('fayda') || label.includes('national id'),
|
||||
() => address.idNumber,
|
||||
);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
// Also re-run after the server seed effect (above) replaces `draft`
|
||||
// wholesale — that effect can resolve after this one, wiping the
|
||||
// prefill back out since the server's own draft has none of this yet.
|
||||
}, [profile?.address, config, detail?.application?.id, detail?.application?.formData]);
|
||||
|
||||
const application = detail?.application;
|
||||
const isAdjusting = application?.status === 'RESUBMIT_REQUIRED';
|
||||
const openRemarks = detail?.openRemarks ?? [];
|
||||
@@ -136,8 +197,9 @@ export function LicenseApplicationPage() {
|
||||
() =>
|
||||
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
|
||||
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
|
||||
language: i18n.language,
|
||||
}),
|
||||
[config, draft],
|
||||
[config, draft, i18n.language],
|
||||
);
|
||||
const sections = useMemo(
|
||||
() => steps.flatMap((step) => step.sections),
|
||||
@@ -154,15 +216,39 @@ export function LicenseApplicationPage() {
|
||||
|
||||
const readOnly = !['DRAFT', 'RESUBMIT_REQUIRED'].includes(application.status);
|
||||
|
||||
// Vessel Information and Current Ownership are separate form sections, so
|
||||
// ConfigDrivenSection (one instance per section) can't fill both itself —
|
||||
// it reports the pick up here and this fans it out across every section.
|
||||
function handleVesselSelected(vessel: Vessel) {
|
||||
for (const section of config?.licenseType.formSchema.sections ?? []) {
|
||||
fillFromVessel(vessel, section.fields, (key, value) => {
|
||||
setDraft((prev) => ({
|
||||
...prev,
|
||||
[section.key]: { ...(prev[section.key] ?? {}), [key]: value },
|
||||
}));
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function saveSection(sectionKey: string) {
|
||||
// During an adjustment round only flagged sections are editable, so don't
|
||||
// even attempt a write the server would reject.
|
||||
if (isAdjusting && !flaggedSections[sectionKey]) return;
|
||||
const values = { ...(draft[sectionKey] ?? {}) };
|
||||
// The picker works in alpha-2 codes (CountrySelect); the backend, like
|
||||
// the profile Address endpoint, stores the full country name.
|
||||
const nationalityField = config?.licenseType.formSchema.sections
|
||||
.find((s) => s.key === sectionKey)
|
||||
// English-pinned: same reasoning as the fill() matcher above.
|
||||
?.fields.find((f) => f.key === 'nationality' || (f.label.en ?? '').toLowerCase().includes('nationality'));
|
||||
if (nationalityField && values[nationalityField.key]) {
|
||||
values[nationalityField.key] = getCountryName(values[nationalityField.key] as string) || values[nationalityField.key];
|
||||
}
|
||||
try {
|
||||
await patchSection({
|
||||
id: appId as string,
|
||||
sectionKey,
|
||||
values: draft[sectionKey] ?? {},
|
||||
values,
|
||||
}).unwrap();
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
@@ -176,7 +262,7 @@ export function LicenseApplicationPage() {
|
||||
async function handleSubmit() {
|
||||
setIssues([]);
|
||||
if (!readOnly && currentStep?.sections?.length) {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
if (Object.keys(errors).length) {
|
||||
notifications.show({
|
||||
@@ -236,7 +322,7 @@ export function LicenseApplicationPage() {
|
||||
if (!currentStep || !config) return true;
|
||||
|
||||
if (currentStep.kind === 'sections') {
|
||||
const errors = validateSections(currentStep.sections, draft);
|
||||
const errors = validateSections(currentStep.sections, draft, i18n.language);
|
||||
setFieldErrors(errors);
|
||||
const count = Object.keys(errors).length;
|
||||
if (count > 0) {
|
||||
@@ -394,7 +480,9 @@ export function LicenseApplicationPage() {
|
||||
section={section}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
onVesselSelected={handleVesselSelected}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly || locked}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
@@ -523,9 +611,11 @@ export function LicenseApplicationPage() {
|
||||
</Text>
|
||||
<ConfigDrivenSection
|
||||
section={section}
|
||||
onVesselSelected={handleVesselSelected}
|
||||
values={draft[section.key] ?? {}}
|
||||
formData={draft}
|
||||
errors={fieldErrors}
|
||||
vessels={vessels}
|
||||
disabled={readOnly}
|
||||
onChange={(key, value) => {
|
||||
setDraft((prev) => ({
|
||||
@@ -620,17 +710,19 @@ export function LicenseApplicationPage() {
|
||||
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
|
||||
min={0}
|
||||
/>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
<ModalFooter>
|
||||
<Button
|
||||
onClick={async () => {
|
||||
if (!newStaff.fullName.trim() || !staffModal) return;
|
||||
await addStaff({ id: appId, roleKey: staffModal, ...newStaff });
|
||||
setNewStaff({ fullName: '', position: '', yearsOfExperience: 0 });
|
||||
setStaffModal(null);
|
||||
refetch();
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
</Stack>
|
||||
</Modal>
|
||||
</Container>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
/* Segmented "pill" tab bar — matches ProfilePage's tab styling. */
|
||||
.list {
|
||||
display: inline-flex;
|
||||
gap: 6px;
|
||||
padding: 5px;
|
||||
background: var(--mantine-color-gray-light);
|
||||
border-radius: var(--mantine-radius-md);
|
||||
border: none;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.tab {
|
||||
border: none;
|
||||
border-radius: 10px;
|
||||
padding: 9px 18px;
|
||||
font-weight: 500;
|
||||
color: var(--mantine-color-dimmed);
|
||||
background: transparent;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
box-shadow 120ms ease;
|
||||
}
|
||||
|
||||
.tab:hover {
|
||||
background: transparent;
|
||||
color: var(--mantine-color-text);
|
||||
}
|
||||
|
||||
.tab[data-active],
|
||||
.tab[data-active]:hover {
|
||||
background: var(--mantine-color-body);
|
||||
color: var(--mantine-color-emaPrimary-7);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--mantine-shadow-xs);
|
||||
}
|
||||
|
||||
/* Clickable stat tile that doubles as a filter toggle. */
|
||||
.tile {
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--mantine-color-default-border);
|
||||
transition:
|
||||
border-color 120ms ease,
|
||||
background-color 120ms ease;
|
||||
}
|
||||
|
||||
.tile:hover {
|
||||
border-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
|
||||
.tileActive,
|
||||
.tileActive:hover {
|
||||
border-color: var(--mantine-color-emaPrimary-6);
|
||||
background: var(--mantine-color-emaPrimary-light);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user