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:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

@@ -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 |

View File

@@ -0,0 +1,2 @@
// Analytics API - Coming soon
// test from claude

View File

@@ -0,0 +1 @@
// Analytics types - Coming soon

View File

@@ -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>

View File

@@ -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>
),
};
}

View File

@@ -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>
),
},

View File

@@ -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>
);

View File

@@ -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,

View File

@@ -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: {
export function professionActionsColumn(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,
},
];
}): 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>
),
};
}

View File

@@ -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),
},
];
}

View File

@@ -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,89 +89,124 @@ 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) => ({
const deptOptions = departments
.filter((d) => d?.status?.toLowerCase() === "active")
.map((d) => ({
value: d.id,
label: d.name?.[locale] ?? d.name ?? '',
label: d.name?.[locale] ?? d.name ?? "",
}));
const resetProfForm = useCallback(() => {
@@ -160,24 +219,28 @@ function ProfessionTab() {
setShowProfForm(true);
}, []);
const handleDeleteProf = useCallback((prof: Profession) => {
const handleDeleteProf = useCallback(
(prof: Profession) => {
setDeleteTarget(prof);
openDelete();
}, [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 handleProfSubmit = useCallback(
async (values: ProfFormValues) => {
const name = { en: values.nameEn, am: values.nameAm };
const description = { en: values.descEn, am: values.descAm };
@@ -189,38 +252,67 @@ function ProfessionTab() {
description,
departmentId: values.departmentId,
}).unwrap();
notify.success(t('configuration.updated'));
notify.success(t("configuration.updated"));
} else {
await createProfession({
departmentId: values.departmentId,
name,
description,
}).unwrap();
notify.success(t('configuration.created'));
notify.success(t("configuration.created"));
}
resetProfForm();
} catch {
notify.error(t('configuration.error'));
} catch (e) {
handleError(e);
}
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
},
[
editingProf,
createProfession,
updateProfession,
resetProfForm,
handleError,
],
);
const getDeptName = useCallback((deptId: string) => {
const getDeptName = useCallback(
(deptId: string) => {
const dept = departments.find((d) => d.id === deptId);
return dept ? (dept.name?.[locale] ?? dept.name?.en ?? '-') : '-';
}, [departments, locale]);
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>

View File

@@ -28,7 +28,8 @@ export interface Profession {
}
export interface ListResponse<T> {
count: number;
count?: number;
total?: number;
items: T[];
}

View File

@@ -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>
),
},

View File

@@ -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>

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}
/>
)}

View File

@@ -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,
},
];
}

View File

@@ -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}
/>
)}

View File

@@ -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>
)}

View File

@@ -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 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 || '';
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>

View File

@@ -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>
),
};
}

View File

@@ -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>
),
},

View File

@@ -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,60 +49,178 @@ 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.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 />
<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>
<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" />
<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>
@@ -109,39 +228,118 @@ function ExamForm({
<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 />
<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" />
<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>
);

View File

@@ -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;

View File

@@ -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>
),
};
}

View File

@@ -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' },
export function itemColumns(showDate: (date: string) => string): AdvancedColumn<Item>[] {
return [
{ header: 'Name', accessorKey: 'name' },
{
key: 'status',
header: 'Status',
render: (item) => <Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>,
cell: ({ row }) => (
<Badge color={STATUS_COLORS[row.original.status]}>{row.original.status}</Badge>
),
},
{
key: 'createdAt',
header: 'Created',
render: (item) => new Date(item.createdAt).toLocaleDateString(),
cell: ({ row }) => showDate(row.original.createdAt),
},
];
}

View File

@@ -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"
/>
);
}

View File

@@ -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>

View File

@@ -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.'
}
/>

View File

@@ -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>

View File

@@ -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 && (

View File

@@ -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>
);

View File

@@ -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,16 +165,18 @@ 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}
<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>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
{verdict && (
<Tooltip
label={
@@ -206,6 +211,11 @@ export function DocumentsTab({
</Badge>
)}
</Group>
<Text size="xs" c="dimmed" truncate>
{file?.originalName ?? t('review.documents.noFile', 'No file')}
</Text>
</div>
</Group>
<Group gap="xs" wrap="nowrap">
<Tooltip
@@ -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%' }}
/>
) : (

View File

@@ -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',
});
}

View File

@@ -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 },

View File

@@ -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>
),
};
}

View File

@@ -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>
),
},
];
}

View File

@@ -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,18 +296,28 @@ 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 ||
@@ -276,11 +326,55 @@ export function LicenseQueuePage() {
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
}
/>
) : (
<>
<AdvancedTable
columns={licenseQueueColumns(t, i18n.language, {
claiming,
onClaim: (app) => handleClaim(app.id),
onOpen: (app) => navigate(`/licence-review/${app.id}`),
<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={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']>),
}}
pagination={{
page,
totalPages: pageCount,
onPageChange: (next) => {
tableName={t("queue.title", "Licence applications")}
itemCount={total}
pageIndex={page - 1}
onPageChange={(pageIndex) => {
const next = pageIndex + 1;
setPage(next);
updateUrl({}, view, next);
},
}}
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)' }
pageSize={pageSize}
onPageSizeChange={(next) => {
setPageSize(next);
setPage(1);
updateUrl({}, view, 1);
}}
refresh={() => active.refetch()}
isLoading={active.isFetching}
verticalSpacing={density === "compact" ? 4 : "sm"}
rowStyle={(_row, index) =>
// Keyboard cursor. A left border rather than a background keeps
// it distinguishable from row selection and from hover.
index === cursor
? { boxShadow: "inset 3px 0 0 var(--mantine-color-blue-6)" }
: undefined
}
/>
<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>

View File

@@ -1,50 +1,45 @@
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) => (
size: 260,
cell: ({ row }) => {
const member = row.original;
return (
<>
<Checkbox
size="xs"
@@ -74,7 +69,8 @@ export function reviewStaffColumns(
/>
)}
</>
),
);
},
},
];
}

View File

@@ -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,10 +801,15 @@ export function LicenseReviewPage() {
</Tabs.Panel>
<Tabs.Panel value="staff">
<Card withBorder padding="md">
<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) => ({
@@ -775,11 +817,13 @@ export function LicenseReviewPage() {
[member.id]: { ...p[member.id], remark },
})),
})}
data={data.staff}
rowKey={(member) => member.id}
onRefresh={refetch}
data={staffPaged.rows}
itemCount={staffPaged.itemCount}
pageIndex={staffPaged.pageIndex}
onPageChange={staffTable.setPageIndex}
pageSize={staffTable.pageSize}
refresh={refetch}
/>
</Card>
</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">

View File

@@ -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,
};
}

View File

@@ -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 && (

View File

@@ -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>
),
};
}

View File

@@ -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],
},
];
}

View File

@@ -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, {
tableName={t('location.manageTypes')}
columns={[
...locationTypeColumns(t, locale),
locationTypeColumnActions(t, {
onEdit: handleEdit,
onDelete: (type) => handleDelete(type.id),
})}
loading={isLoading}
onRefresh={refetch}
emptyTitle={t('location.noTypes')}
}),
]}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
isLoading={isLoading}
refresh={refetch}
emptyText={t('location.noTypes')}
/>
</Modal>
);

View File

@@ -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} />

View File

@@ -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>
),
},

View File

@@ -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>

View File

@@ -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>
),
};
}

View File

@@ -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,143 +16,115 @@ 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>
),
},
];

View File

@@ -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,64 +179,102 @@ 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> => {
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, 'Could not record the ruling'));
notify.error(
extractErrorMessage(error, t('recordVerification.rulingFailed', 'Could not record the ruling')),
);
}
};
},
[t],
);
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
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.
</Text>
const pendingMedicalList = useMemo(
() => pendingMedical ?? [],
[pendingMedical],
);
const pendingSeaServiceList = useMemo(
() => pendingSeaService ?? [],
[pendingSeaService],
);
<Tabs defaultValue="medical" keepMounted={false}>
<Tabs.List>
<Tabs.Tab value="medical" leftSection={<IconStethoscope size={16} />}>
Medical ({pendingMedical?.length ?? 0})
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
Sea Service ({pendingSeaService?.length ?? 0})
</Tabs.Tab>
</Tabs.List>
const pagedMedical = useMemo(() => {
const start = medicalPage * medicalPageSize;
return pendingMedicalList.slice(start, start + medicalPageSize);
}, [pendingMedicalList, medicalPage, medicalPageSize]);
<Tabs.Panel value="medical" pt="md">
<Card withBorder padding={0}>
<AdvancedTable
columns={medicalColumns({
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(
() =>
@@ -141,24 +282,28 @@ export function MedicalVerificationPage() {
id: certificate.id,
outcome: 'VERIFIED',
}).unwrap(),
'Certificate verified',
t('recordVerification.certificateVerified', 'Certificate verified'),
),
onReject: setRejectMedical,
})}
data={pendingMedical ?? []}
rowKey={(certificate) => certificate.id}
loading={loadingMedical}
onRefresh={refetchMedical}
emptyTitle="Nothing awaiting verification."
/>
</Card>
</Tabs.Panel>
onReject: (certificate) => setRejectMedical(certificate),
}),
],
[rulingMedical, rule, verifyMedical, showDate, t],
);
<Tabs.Panel value="sea-service" pt="md">
<Card withBorder padding={0}>
<AdvancedTable
columns={seaServiceColumns({
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(
() =>
@@ -166,22 +311,91 @@ export function MedicalVerificationPage() {
id: record.id,
outcome: 'VERIFIED',
}).unwrap(),
'Sea-service record verified',
t('recordVerification.seaServiceVerified', 'Sea-service record verified'),
),
onReject: setRejectSeaService,
onReject: (record) => setRejectSeaService(record),
}),
],
[rulingSeaService, rule, verifySeaService, showDate, t],
);
return (
<Container size="xl" py="md">
<Title order={3} mb={4}>
{t('recordVerification.title', 'Record verification')}
</Title>
<Text size="sm" c="dimmed" mb="md">
{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} />}>
{t('recordVerification.tabs.medical', {
count: pendingMedicalList.length,
defaultValue: 'Medical ({{count}})',
})}
data={pendingSeaService ?? []}
rowKey={(record) => record.id}
loading={loadingSeaService}
onRefresh={refetchSeaService}
emptyTitle="Nothing awaiting verification."
</Tabs.Tab>
<Tabs.Tab value="sea-service" leftSection={<IconAnchor size={16} />}>
{t('recordVerification.tabs.seaService', {
count: pendingSeaServiceList.length,
defaultValue: 'Sea Service ({{count}})',
})}
</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="medical" pt="md">
<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">
<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.')}
/>
</Card>
</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);
}}

View File

@@ -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>
),
};
}

View File

@@ -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.
return (
<Text size="sm" c="dimmed">
</Text>
) : type.feeRenewal === null ? (
<Tooltip label="No separate renewal fee — renewal is charged at the new-application rate">
);
}
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">
(same as new)
{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>
),
},
];
}

View File

@@ -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"
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}
/>
</Card>
<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>

View File

@@ -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">
<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} />}

View File

@@ -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>
);
},
};
}

View File

@@ -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>
),
},
];

View File

@@ -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>
);

View File

@@ -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 }}
/>

View File

@@ -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>
</>
)}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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>
);
},
};
}

View File

@@ -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>,
},
];
}

View File

@@ -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>

View File

@@ -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,
};
}

View File

@@ -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>
),
},
];

View File

@@ -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>

View File

@@ -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

View 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,
}));
}

View File

@@ -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;

View File

@@ -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;

View File

@@ -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>

View File

@@ -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: {
export function vesselRegistrationQueueColumns(
showDate: (value: string | number | Date | null | undefined) => string,
handlers: {
onStatus: (vessel: Vessel) => void;
}): AdvancedTableColumn<Vessel>[] {
},
): 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

View File

@@ -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}
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)}
emptyTitle={
emptyText={
search ? 'No vessels match that search.' : 'No vessels registered yet.'
}
/>
</Card>
<VesselDetailDrawer vessel={detail} onClose={() => setDetail(null)} />
<StatusModal

File diff suppressed because it is too large Load Diff

View File

@@ -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;

View File

@@ -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 ? {
navbar={
isSidebar
? {
width: collapsed ? 72 : 264,
breakpoint: 'sm',
breakpoint: "sm",
collapsed: { mobile: !opened },
} : undefined}
}
: 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

View File

@@ -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',

View File

@@ -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 /> },

View File

@@ -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';

View File

@@ -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.

View File

@@ -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>
);
}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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"
/>

View File

@@ -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,14 +456,19 @@ function ApplicationTable({
navigate: (path: string) => void;
onRefresh: () => void;
}) {
const table = useServerTable();
const paged = table.paginate(applications);
return (
<Card withBorder radius="md" padding={0}>
<AdvancedTable
tableName="My applications"
columns={dashboardApplicationColumns}
data={applications}
rowKey={(app) => app.id}
data={paged.rows}
itemCount={paged.itemCount}
pageIndex={paged.pageIndex}
onPageChange={table.setPageIndex}
pageSize={table.pageSize}
verticalSpacing="sm"
onRefresh={onRefresh}
refresh={onRefresh}
onRowClick={(app) =>
navigate(
app.licenseType?.key
@@ -505,92 +477,6 @@ function ApplicationTable({
)
}
/>
</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>
);
}

View File

@@ -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 (
<Container size="lg" py="xl">
<FeatureUnavailable
title="Endorsements"
description="Endorsements are not connected to the backend yet."
<Group justify="center" py="xl">
<Loader />
</Group>
);
}
return (
<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'
}
/>
</Container>
</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>
);
}

View File

@@ -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>

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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;

View File

@@ -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 (

View File

@@ -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',

View File

@@ -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;

View File

@@ -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,6 +710,7 @@ export function LicenseApplicationPage() {
onChange={(v) => setNewStaff({ ...newStaff, yearsOfExperience: Number(v) || 0 })}
min={0}
/>
<ModalFooter>
<Button
onClick={async () => {
if (!newStaff.fullName.trim() || !staffModal) return;
@@ -631,6 +722,7 @@ export function LicenseApplicationPage() {
>
Add
</Button>
</ModalFooter>
</Stack>
</Modal>
</Container>

View File

@@ -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