Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange

This commit is contained in:
Nati
2026-08-29 06:52:20 +00:00
55 changed files with 2138 additions and 231 deletions

View File

@@ -1,9 +1,9 @@
import type { Bilingual, LicenseCategory, LicenseTemplate, LicenseType } from '@ema-platform/api';
import { BASE_API_URL } from '@ema-platform/api';
/** Same resolution — and same fallback — the shared RTK Query baseQuery uses. */
export const API_BASE_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
export const API_BASE_URL = BASE_API_URL;
export const STATUS_COLOR: Record<LicenseTemplate['status'], string> = {
DRAFT: 'gray',

View File

@@ -12,6 +12,7 @@ import type {
CreateIncidentPayload,
ResolveIncidentPayload,
RegradeOutcome,
GradingSheet,
} from '../types/exam';
const examApi = baseApi.injectEndpoints({
@@ -101,6 +102,15 @@ const examApi = baseApi.injectEndpoints({
}),
invalidatesTags: ['Api'],
}),
/** The candidate's answers plus auto-computable CHOICE scores, for RecordResultModal. */
getGradingSheet: builder.query<
GradingSheet,
{ examId: string; profileId: string }
>({
query: ({ examId, profileId }) =>
`/exam-attempts/exam/${examId}/candidate/${profileId}/grading-sheet`,
providesTags: ['Api'],
}),
}),
overrideExisting: false,
});
@@ -119,4 +129,5 @@ export const {
useRecordIncidentMutation,
useResolveIncidentMutation,
useRegradeAttemptMutation,
useGetGradingSheetQuery,
} = examApi;

View File

@@ -22,6 +22,7 @@ import {
TextInput,
ThemeIcon,
Box,
Tooltip,
rem,
} from "@mantine/core";
import { useDisclosure } from "@mantine/hooks";
@@ -48,6 +49,7 @@ import {
useUpdateExamMutation,
useAssignQuestionsMutation,
useSelectRandomQuestionsMutation,
useGetExamRegistrationsQuery,
} from '../api/exam-api';
import { useGetQuestionsQuery } from '../../question/api/question-api';
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
@@ -114,6 +116,12 @@ export function ExamDetailPage() {
const [selectRandom, { isLoading: isDrawing }] = useSelectRandomQuestionsMutation();
const { data: exam, isLoading, isError } = useGetExamQuery(id ?? '', { skip: !id });
// The backend locks the paper the moment the first candidate registers
// (ExamService.assertPaperEditable) — every candidate must sit the same
// paper. Same query ExamCandidatesPanel already runs, so RTK Query serves
// it from cache rather than issuing a second request.
const { data: registrations } = useGetExamRegistrationsQuery(id ?? '', { skip: !id });
const paperLocked = (registrations?.length ?? 0) > 0;
const { data: qRes } = useGetQuestionsQuery();
const { data: certRes } = useGetCertificationsQuery();
const allQuestions = qRes?.items ?? [];
@@ -122,10 +130,14 @@ export function ExamDetailPage() {
// Only approved bank items may go on a paper (US-EXAM-003), so the picker
// must not offer drafts or retired questions either.
//
// BOTH describes a mixed paper — a question itself is never "BOTH" (see
// Filters on exam.form alone, not administrationMethod: the backend no
// longer restricts ONLINE to CHOICE (ExamService no longer has an
// assertOnlineIsChoiceOnly gate), so exam.form is now the sole source of
// truth for what belongs on the paper, ONLINE or OFFLINE alike. BOTH
// describes a mixed paper — a question itself is never "BOTH" (see
// QuestionForm), so an equality check against it would match nothing and
// silently offer zero questions. Same skip-condition as the backend's own
// random draw (ExamService.selectRandomQuestions).
// silently offer zero questions; skipped the same way the backend's own
// random draw does (ExamService.selectRandomQuestions).
const eligibleQuestions = useMemo(() => {
if (!exam) return [];
return allQuestions
@@ -179,7 +191,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, t('exam.randomError'));
notify.error(
key.startsWith('insufficient_approved_questions')
key === 'paper_locked_after_registration'
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
: key.startsWith('insufficient_approved_questions')
? `${t('exam.notEnoughApproved')} (${key.split(':')[1] ?? ''})`
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -200,7 +214,9 @@ export function ExamDetailPage() {
} catch (error) {
const key = extractErrorMessage(error, 'Failed to assign questions');
notify.error(
key.startsWith('question_not_approved')
key === 'paper_locked_after_registration'
? t('exam.paperLockedHint', { count: registrations?.length ?? 0 })
: key.startsWith('question_not_approved')
? t('question.qc.onlyApprovedUsable')
: key.startsWith('paper_cannot_reach_cutting_point')
? t('exam.cannotReachCuttingPoint', {
@@ -452,19 +468,45 @@ export function ExamDetailPage() {
{t("exam.detail.questionsSection", { pts: totalPoints })}
</Title>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_EXAMS]} hideOnly>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
>
{t("exam.manageQuestions")}
</Button>
<Group gap="xs">
{paperLocked && (
<Badge size="sm" variant="light" color="gray">
{t("exam.paperLocked")}
</Badge>
)}
<Tooltip
label={t("exam.paperLockedHint", {
count: registrations?.length ?? 0,
})}
disabled={!paperLocked}
multiline
w={280}
>
{/* Wrapped: a disabled Mantine Button fires no pointer events,
so the tooltip needs an enabled element to hang off. */}
<Box>
<Button
variant="light"
size="xs"
leftSection={<IconPlus size={14} />}
onClick={openAssignModal}
disabled={paperLocked}
>
{t("exam.manageQuestions")}
</Button>
</Box>
</Tooltip>
</Group>
</RequirePermission>
</Group>
{(exam.questions ?? []).length === 0 ? (
<Alert color="gray" icon={<IconInfoCircle size={16} />}>
{t("exam.noQuestionsAssigned")}
<Alert
color={paperLocked ? "red" : "gray"}
icon={<IconInfoCircle size={16} />}
>
{paperLocked
? t("exam.paperLockedHint", { count: registrations?.length ?? 0 })
: t("exam.noQuestionsAssigned")}
</Alert>
) : (
<Stack gap="md">

View File

@@ -79,21 +79,44 @@ function ExamForm({
const [status, setStatus] = useState<string | null>(editing?.status ?? null);
const [activeTab, setActiveTab] = useState<string | null>("basic");
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
/**
* Split per tab so "Next" can check just the tab in front of the user.
* Submitting from Basic Info used to complain about Settings fields the
* user had not been shown yet — the error was correct and unactionable at
* the same time. Each returns the message key for what is missing, or null.
*/
const validateBasic = (): string | null => {
if (!certificationId || !titleEn || !titleAm || !date || !venue) {
setActiveTab("basic");
notify.error(t("exam.form.fillRequiredBasic"));
return;
return "exam.form.fillRequiredBasic";
}
if ((directionEn || directionAm) && !(directionEn && directionAm)) {
setActiveTab("basic");
notify.error(t("exam.form.directionBothLanguages"));
return "exam.form.directionBothLanguages";
}
return null;
};
const validateSettings = (): string | null =>
!type || !form || !adminMethod || !evalMethod || !cuttingPoint
? "exam.form.fillRequiredSettings"
: null;
const goNext = () => {
const error = validateBasic();
if (error) {
notify.error(t(error));
return;
}
if (!type || !form || !adminMethod || !evalMethod || !cuttingPoint) {
setActiveTab("settings");
notify.error(t("exam.form.fillRequiredSettings"));
setActiveTab("settings");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
// Still checks both: the tabs are clickable, so a user can reach Settings
// without going through Next.
const error = validateBasic() ?? validateSettings();
if (error) {
setActiveTab(error === "exam.form.fillRequiredSettings" ? "settings" : "basic");
notify.error(t(error));
return;
}
onSubmit(
@@ -255,12 +278,6 @@ function ExamForm({
onChange={setForm}
size="sm"
required
disabled={adminMethod === "ONLINE"}
description={
adminMethod === "ONLINE"
? t("exam.form.onlineChoiceOnlyHint")
: undefined
}
/>
<Select
label={t("exam.detail.administration")}
@@ -270,14 +287,7 @@ function ExamForm({
{ value: "ONLINE", label: t("exam.form.online") },
]}
value={adminMethod}
onChange={(value) => {
setAdminMethod(value);
// Online exams are graded automatically, and that only
// has an answer model for CHOICE — matches the backend
// rule (online_exam_requires_choice_form), not just a
// UI nicety.
if (value === "ONLINE") setForm("CHOICE");
}}
onChange={setAdminMethod}
size="sm"
required
/>
@@ -350,9 +360,26 @@ function ExamForm({
<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>
{activeTab === "basic" ? (
/* Not type="submit": Basic Info is not the last step, so the
primary action advances rather than saves. */
<Button size="sm" onClick={goNext}>
{t("exam.form.next")}
</Button>
) : (
<>
<Button
variant="default"
size="sm"
onClick={() => setActiveTab("basic")}
>
{t("exam.form.back")}
</Button>
<Button type="submit" size="sm" loading={isSubmitting}>
{editing ? t("exam.update") : t("exam.create")}
</Button>
</>
)}
</ModalFooter>
</form>
</Modal>

View File

@@ -133,6 +133,24 @@ export interface ExamRegistration {
export type RegradeOutcome =
{ graded: true; resultId: string } | { graded: false; reason: string };
/** One question's row on the staff grading sheet — the candidate's own
* answer plus the auto-computable score, where one exists. */
export interface GradingSheetQuestion {
questionId: string;
form: QuestionForm;
points: number;
answerText: string | null;
selectedOptionId: string | null;
selectedOptionText: { en?: string; am?: string } | null;
/** null means "no auto-score" — examiner enters one by hand. */
autoScore: number | null;
}
export interface GradingSheet {
attemptStatus: 'IN_PROGRESS' | 'SUBMITTED' | 'EXPIRED';
questions: GradingSheetQuestion[];
}
export interface RecordAttendancePayload {
registrationId: string;
status: AttendanceStatus;

View File

@@ -22,6 +22,9 @@ interface Props {
* of a per-candidate appointment. Scoped to sittings whose certification
* matches this application's rank, so a Chief Mate candidate cannot be seated
* into an OOW Deck sitting by accident.
*
* Scheduling makes the sitting available; it does not register the candidate.
* That is their own act, from the portal's Register button.
*/
export function ScheduleExamModal({
opened,
@@ -61,7 +64,7 @@ export function ScheduleExamModal({
<Text size="sm" c="dimmed">
{t('review.scheduleExam.intro', {
defaultValue:
'Assign {{applicant}} to a scheduled sitting. They will be notified with the date and their admission number.',
'Make a sitting available to {{applicant}}. They register for it themselves from the portal.',
applicant: applicantName,
})}
</Text>
@@ -89,7 +92,7 @@ export function ScheduleExamModal({
<Text size="xs" c="dimmed">
{t(
'review.scheduleExam.admissionHint',
'An admission number is issued automatically when the candidate is seated.',
'Scheduling does not seat the candidate. They must register for the sitting from the portal, and the admission number is issued then.',
)}
</Text>

View File

@@ -1,14 +1,21 @@
import type { Dispatch, ReactNode, SetStateAction } from "react";
import { Badge, Checkbox, Text, Tooltip } from "@mantine/core";
import { Badge, Checkbox, Group, Text, Tooltip } from "@mantine/core";
import type { TFunction } from "i18next";
import {
STATUS_COLORS,
STATUS_LABELS,
applicantOrCompanyName,
localized,
type ApplicationKind,
type LicenseApplication,
type QueueFilter,
} from "@ema-platform/api";
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: "blue",
RENEWAL: "teal",
REISSUE: "orange",
};
import type { AdvancedColumn } from "@ema-platform/ui";
import { dateDisplayer } from "@ema-platform/shared";
import { computeSla } from "../../sla";
@@ -112,9 +119,19 @@ export function licenseQueueColumns(
{
header: t("queue.typeCol", "Type"),
cell: ({ row }) => (
<Text size="sm">
{localized(row.original.licenseType?.name, locale) || "—"}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm">
{localized(row.original.licenseType?.name, locale) || "—"}
</Text>
{row.original.kind !== "NEW" && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
{t(
`queue.kindValues.${row.original.kind}`,
row.original.kind === "RENEWAL" ? "Renewal" : "Replacement",
)}
</Badge>
)}
</Group>
),
},
{

View File

@@ -42,6 +42,7 @@ import {
useGetQueueCountsQuery,
useGetQueueQuery,
useLazyExportApplicationsQuery,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
type LicenseType,
@@ -437,6 +438,7 @@ export function LicenseQueuePage() {
const hasFacets = Boolean(
urlFilter.status?.length ||
urlFilter.licenseTypeId ||
urlFilter.kind ||
urlFilter.assignee ||
urlFilter.submittedFrom ||
debouncedSearch,
@@ -589,6 +591,19 @@ export function LicenseQueuePage() {
w={220}
/>
)}
<Select
label={t("queue.kind", "Application kind")}
placeholder={t("queue.anyType", "Any")}
data={[
{ value: "NEW", label: t("queue.kindValues.NEW", "New") },
{ value: "RENEWAL", label: t("queue.kindValues.RENEWAL", "Renewal") },
{ value: "REISSUE", label: t("queue.kindValues.REISSUE", "Replacement") },
]}
value={urlFilter.kind ?? null}
onChange={(v) => setFacet({ kind: (v as ApplicationKind) ?? undefined })}
clearable
w={180}
/>
<AmharicDatePicker
label={t("queue.submittedFrom", "Submitted from")}
value={urlFilter.submittedFrom ?? ""}

View File

@@ -269,6 +269,9 @@ export function LicenseReviewPage() {
const [rescheduleReason, setRescheduleReason] = useState("");
const [issuanceOpen, setIssuanceOpen] = useState(false);
const [issuanceDate, setIssuanceDate] = useState("");
const [issuancePeriod, setIssuancePeriod] = useState<"MORNING" | "AFTERNOON">(
"MORNING",
);
const [resultOpen, setResultOpen] = useState(false);
const [scheduleExamOpen, setScheduleExamOpen] = useState(false);
const [examOutcomeOpen, setExamOutcomeOpen] = useState(false);
@@ -884,6 +887,11 @@ export function LicenseReviewPage() {
<Badge color={STATUS_COLORS[status]} variant="light">
{t(`queue.statusValues.${status}`, STATUS_LABELS[status])}
</Badge>
{data.issuedLicenseStatus === "SUPERSEDED" && (
<Badge color="gray" variant="light">
{t("review.certificateSuperseded", "Certificate superseded")}
</Badge>
)}
{app.adjustmentRound > 0 && (
<Badge color="orange" variant="light" size="sm">
{t("review.round", {
@@ -1585,6 +1593,14 @@ export function LicenseReviewPage() {
value={issuanceDate}
onChange={setIssuanceDate}
/>
<SegmentedControl
value={issuancePeriod}
onChange={(value) => setIssuancePeriod(value as "MORNING" | "AFTERNOON")}
data={[
{ value: "MORNING", label: t("review.morning", "Morning") },
{ value: "AFTERNOON", label: t("review.afternoon", "Afternoon") },
]}
/>
<ModalFooter>
{/* Span-wrapped like DecisionBar's ActionButton — Mantine strips
pointer events from a disabled control, and a disabled button
@@ -1605,6 +1621,7 @@ export function LicenseReviewPage() {
await scheduleIssuance({
id,
scheduledDate: issuanceDate,
scheduledPeriod: issuancePeriod,
}).unwrap();
setIssuanceOpen(false);
},

View File

@@ -0,0 +1,71 @@
import { Button, Group } from '@mantine/core';
import { IconCheck, IconUserCheck, IconX } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { PickupAppointment } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
export function pickupDeskActionsColumn(
t: TFunction,
handlers: {
onCheckIn: (appointment: PickupAppointment) => void;
onIssue: (appointment: PickupAppointment) => void;
onNoShow: (appointment: PickupAppointment) => void;
},
loadingId: string | null,
): AdvancedColumn<PickupAppointment> {
return {
header: '',
size: 260,
align: 'right',
cell: ({ row }) => {
const appointment = row.original;
const loading = loadingId === appointment.id;
return (
<Group gap="xs" justify="flex-end" wrap="nowrap">
{appointment.status === 'SCHEDULED' && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
<Button
size="xs"
variant="light"
loading={loading}
leftSection={<IconUserCheck size={14} />}
onClick={() => handlers.onCheckIn(appointment)}
>
{t('pickupDesk.checkIn', 'Check in')}
</Button>
</RequirePermission>
)}
{(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && (
<>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.ISSUE_CERTIFICATE]} hideOnly>
<Button
size="xs"
variant="filled"
color="teal"
loading={loading}
leftSection={<IconCheck size={14} />}
onClick={() => handlers.onIssue(appointment)}
>
{t('pickupDesk.issue', 'Issue')}
</Button>
</RequirePermission>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.MANAGE_PICKUP_DESK]} hideOnly>
<Button
size="xs"
variant="subtle"
color="red"
loading={loading}
leftSection={<IconX size={14} />}
onClick={() => handlers.onNoShow(appointment)}
>
{t('pickupDesk.noShow', 'No-show')}
</Button>
</RequirePermission>
</>
)}
</Group>
);
},
};
}

View File

@@ -0,0 +1,51 @@
import { Badge, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { PickupAppointment, PickupOffice } from '@ema-platform/api';
const STATUS_COLOR: Record<PickupAppointment['status'], string> = {
SCHEDULED: 'cyan',
CHECKED_IN: 'yellow',
ISSUED: 'green',
NO_SHOW: 'red',
RESCHEDULED: 'gray',
CANCELLED: 'gray',
};
export function pickupDeskColumns(
t: TFunction,
officesById: Map<string, PickupOffice>,
): AdvancedColumn<PickupAppointment>[] {
return [
{
header: t('pickupDesk.columns.time', 'Time'),
cell: ({ row }) => (
<Text size="sm" fw={600} ff="monospace">
{row.original.slotStartTime}
</Text>
),
},
{
header: t('pickupDesk.columns.appointment', 'Appointment'),
cell: ({ row }) => (
<Text size="sm" ff="monospace">
{row.original.appointmentNumber}
</Text>
),
},
{
header: t('pickupDesk.columns.office', 'Office'),
cell: ({ row }) => (
<Text size="sm">{officesById.get(row.original.officeId)?.name ?? '—'}</Text>
),
},
{
header: t('pickupDesk.columns.status', 'Status'),
cell: ({ row }) => (
<Badge size="sm" variant="light" color={STATUS_COLOR[row.original.status]}>
{row.original.status.replace('_', ' ')}
</Badge>
),
},
];
}

View File

@@ -0,0 +1,150 @@
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Stack, ThemeIcon } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { AmharicDatePicker, AdvancedTable, PageHeader, notify, useServerTable } from '@ema-platform/ui';
import {
extractErrorMessage,
useCheckInPickupMutation,
useGetPickupOfficesQuery,
useGetPickupWorklistQuery,
useIssueCertificateMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
type PickupAppointment,
} from '@ema-platform/api';
import { pickupDeskActionsColumn } from './actions';
import { pickupDeskColumns } from './columns';
function todayIso(): string {
return new Date().toISOString().slice(0, 10);
}
/**
* The pickup officer's worklist for one day (spec §43): who is booked, when,
* and where they are in the visit. Check-in and no-show are pickup-desk
* concerns; Issue calls the existing certificate-issuance endpoint and then
* marks the appointment issued, so the two stay in the same state a
* `SCHEDULED` application has always moved through.
*/
export function PickupDeskPage() {
const { t } = useTranslation();
const [date, setDate] = useState(todayIso());
const [officeId, setOfficeId] = useState<string | null>(null);
const [loadingId, setLoadingId] = useState<string | null>(null);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const { data: offices } = useGetPickupOfficesQuery();
const {
data: appointments,
isFetching,
refetch,
} = useGetPickupWorklistQuery({ date, officeId: officeId ?? undefined });
const [checkIn] = useCheckInPickupMutation();
const [markIssued] = useMarkPickupIssuedMutation();
const [markNoShow] = useMarkPickupNoShowMutation();
const [issueCertificate] = useIssueCertificateMutation();
const officesById = useMemo(
() => new Map((offices ?? []).map((o) => [o.id, o])),
[offices],
);
const officeOptions = useMemo(
() => (offices ?? []).map((o) => ({ value: o.id, label: o.name })),
[offices],
);
const rows = [...(appointments ?? [])].sort((a, b) =>
a.slotStartTime.localeCompare(b.slotStartTime),
);
const page = paginate(rows);
async function withLoading(id: string, action: () => Promise<unknown>) {
setLoadingId(id);
try {
await action();
} catch (err) {
notify.error(extractErrorMessage(err), t('pickupDesk.actionFailed', 'Action failed'));
} finally {
setLoadingId(null);
}
}
async function handleCheckIn(appointment: PickupAppointment) {
await withLoading(appointment.id, () => checkIn(appointment.id).unwrap());
}
async function handleIssue(appointment: PickupAppointment) {
await withLoading(appointment.id, async () => {
// Renders and stores the certificate — the same action a raw
// schedule-only application reaches from the review page.
await issueCertificate(appointment.applicationId).unwrap();
await markIssued(appointment.id).unwrap();
notify.success(t('pickupDesk.issued', 'Document issued'));
});
}
async function handleNoShow(appointment: PickupAppointment) {
await withLoading(appointment.id, () => markNoShow(appointment.id).unwrap());
}
const columns = [
...pickupDeskColumns(t, officesById),
pickupDeskActionsColumn(
t,
{ onCheckIn: handleCheckIn, onIssue: handleIssue, onNoShow: handleNoShow },
loadingId,
),
];
return (
<Stack gap="lg">
<PageHeader
title={t('pickupDesk.title', 'Pickup Desk')}
subtitle={t(
'pickupDesk.subtitle',
"Today's and upcoming document pickup appointments.",
)}
noMargin
action={
<ThemeIcon size="xl" radius="md" variant="light">
<IconCalendarEvent size={22} />
</ThemeIcon>
}
/>
<Group gap="sm">
<AmharicDatePicker
label={t('pickupDesk.date', 'Date')}
value={date}
onChange={setDate}
/>
<Select
label={t('pickupDesk.office', 'Office')}
placeholder={t('pickupDesk.allOffices', 'All offices')}
data={officeOptions}
value={officeId}
onChange={setOfficeId}
clearable
/>
</Group>
<AdvancedTable
tableName="pickup-desk-appointments"
columns={columns}
data={page.rows}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
emptyText={t('pickupDesk.empty', 'No appointments for this day.')}
/>
</Stack>
);
}
export default PickupDeskPage;

View File

@@ -0,0 +1,309 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Badge,
Button,
Group,
Modal,
MultiSelect,
NumberInput,
Stack,
Switch,
Text,
TextInput,
ThemeIcon,
} from '@mantine/core';
import { IconBuildingWarehouse, IconPlus } from '@tabler/icons-react';
import { AdvancedTable, ModalFooter, PageHeader, notify, useServerTable, type AdvancedColumn } from '@ema-platform/ui';
import {
extractErrorMessage,
useCreatePickupOfficeMutation,
useGetPickupOfficesQuery,
useUpdatePickupOfficeMutation,
type PickupOffice,
} from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
const WEEKDAYS = [
{ value: '0', label: 'Sun' },
{ value: '1', label: 'Mon' },
{ value: '2', label: 'Tue' },
{ value: '3', label: 'Wed' },
{ value: '4', label: 'Thu' },
{ value: '5', label: 'Fri' },
{ value: '6', label: 'Sat' },
];
type OfficeDraft = {
name: string;
address: string;
workingDays: string[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
};
const EMPTY_DRAFT: OfficeDraft = {
name: '',
address: '',
workingDays: ['1', '2', '3', '4', '5'],
startTime: '08:30',
endTime: '17:00',
slotDurationMinutes: 30,
maxApplicantsPerSlot: 10,
rescheduleMinNoticeHours: 24,
isActive: true,
};
function toDraft(office: PickupOffice): OfficeDraft {
return {
name: office.name,
address: office.address ?? '',
workingDays: office.workingDays.map(String),
startTime: office.startTime,
endTime: office.endTime,
slotDurationMinutes: office.slotDurationMinutes,
maxApplicantsPerSlot: office.maxApplicantsPerSlot,
rescheduleMinNoticeHours: office.rescheduleMinNoticeHours,
isActive: office.isActive,
};
}
/**
* Office/location, working hours, slot capacity and reschedule cutoff — the
* configuration `PickupService.availableSlots` computes real slots from
* (spec §20). Holiday management lives here too, one office at a time,
* rather than a separate page — a holiday has no meaning without an office.
*/
export function PickupOfficesPage() {
const { t } = useTranslation();
const { data: offices, isFetching, refetch } = useGetPickupOfficesQuery();
const [createOffice, { isLoading: creating }] = useCreatePickupOfficeMutation();
const [updateOffice, { isLoading: updating }] = useUpdatePickupOfficeMutation();
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable();
const [editing, setEditing] = useState<PickupOffice | null>(null);
const [creatingNew, setCreatingNew] = useState(false);
const [draft, setDraft] = useState<OfficeDraft>(EMPTY_DRAFT);
const page = paginate(offices ?? []);
function openEdit(office: PickupOffice) {
setEditing(office);
setDraft(toDraft(office));
}
function openCreate() {
setCreatingNew(true);
setDraft(EMPTY_DRAFT);
}
function close() {
setEditing(null);
setCreatingNew(false);
}
async function save() {
const body = {
name: draft.name,
address: draft.address || undefined,
workingDays: draft.workingDays.map(Number),
startTime: draft.startTime,
endTime: draft.endTime,
slotDurationMinutes: draft.slotDurationMinutes,
maxApplicantsPerSlot: draft.maxApplicantsPerSlot,
rescheduleMinNoticeHours: draft.rescheduleMinNoticeHours,
isActive: draft.isActive,
};
try {
if (editing) {
await updateOffice({ id: editing.id, ...body }).unwrap();
} else {
await createOffice(body).unwrap();
}
notify.success(t('pickupOffices.saved', 'Office saved'));
close();
} catch (err) {
notify.error(extractErrorMessage(err), t('pickupOffices.saveFailed', 'Could not save'));
}
}
const columns: AdvancedColumn<PickupOffice>[] = [
{
header: t('pickupOffices.columns.name', 'Office'),
cell: ({ row }) => (
<>
<Text size="sm" fw={600}>
{row.original.name}
</Text>
<Text size="xs" c="dimmed">
{row.original.address ?? '—'}
</Text>
</>
),
},
{
header: t('pickupOffices.columns.hours', 'Working hours'),
cell: ({ row }) => (
<Text size="sm">
{row.original.startTime}{row.original.endTime}
</Text>
),
},
{
header: t('pickupOffices.columns.capacity', 'Capacity / slot'),
cell: ({ row }) => (
<Text size="sm">
{row.original.maxApplicantsPerSlot} · {row.original.slotDurationMinutes}min
</Text>
),
},
{
header: t('pickupOffices.columns.status', 'Status'),
cell: ({ row }) => (
<Badge size="sm" color={row.original.isActive ? 'teal' : 'gray'} variant="light">
{row.original.isActive
? t('pickupOffices.active', 'Active')
: t('pickupOffices.inactive', 'Inactive')}
</Badge>
),
},
{
header: '',
align: 'right',
cell: ({ row }) => (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
<Button size="xs" variant="light" onClick={() => openEdit(row.original)}>
{t('pickupOffices.edit', 'Edit')}
</Button>
</RequirePermission>
),
},
];
return (
<Stack gap="lg">
<PageHeader
title={t('pickupOffices.title', 'Pickup Offices')}
subtitle={t(
'pickupOffices.subtitle',
'Where applicants collect printed documents, and how many can be booked into each slot.',
)}
noMargin
action={
<Group gap="sm">
<ThemeIcon size="xl" radius="md" variant="light">
<IconBuildingWarehouse size={22} />
</ThemeIcon>
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CONFIGURE_PICKUP_OFFICES]} hideOnly>
<Button leftSection={<IconPlus size={16} />} onClick={openCreate}>
{t('pickupOffices.new', 'New office')}
</Button>
</RequirePermission>
</Group>
}
/>
<AdvancedTable
tableName="pickup-offices"
columns={columns}
data={page.rows}
itemCount={page.itemCount}
pageIndex={page.pageIndex}
onPageChange={setPageIndex}
pageSize={pageSize}
onPageSizeChange={setPageSize}
refresh={refetch}
isLoading={isFetching}
/>
<Modal
opened={Boolean(editing) || creatingNew}
onClose={close}
title={editing ? t('pickupOffices.editTitle', 'Edit office') : t('pickupOffices.new', 'New office')}
>
<Stack>
<TextInput
label={t('pickupOffices.form.name', 'Name')}
value={draft.name}
onChange={(e) => setDraft((d) => ({ ...d, name: e.currentTarget.value }))}
withAsterisk
/>
<TextInput
label={t('pickupOffices.form.address', 'Address')}
value={draft.address}
onChange={(e) => setDraft((d) => ({ ...d, address: e.currentTarget.value }))}
/>
<MultiSelect
label={t('pickupOffices.form.workingDays', 'Working days')}
data={WEEKDAYS}
value={draft.workingDays}
onChange={(v) => setDraft((d) => ({ ...d, workingDays: v }))}
/>
<Group grow>
<TextInput
label={t('pickupOffices.form.startTime', 'Start time')}
placeholder="08:30"
value={draft.startTime}
onChange={(e) => setDraft((d) => ({ ...d, startTime: e.currentTarget.value }))}
/>
<TextInput
label={t('pickupOffices.form.endTime', 'End time')}
placeholder="17:00"
value={draft.endTime}
onChange={(e) => setDraft((d) => ({ ...d, endTime: e.currentTarget.value }))}
/>
</Group>
<Group grow>
<NumberInput
label={t('pickupOffices.form.slotDuration', 'Slot length (min)')}
min={5}
value={draft.slotDurationMinutes}
onChange={(v) =>
setDraft((d) => ({ ...d, slotDurationMinutes: Number(v) || d.slotDurationMinutes }))
}
/>
<NumberInput
label={t('pickupOffices.form.capacity', 'Max per slot')}
min={1}
value={draft.maxApplicantsPerSlot}
onChange={(v) =>
setDraft((d) => ({ ...d, maxApplicantsPerSlot: Number(v) || d.maxApplicantsPerSlot }))
}
/>
</Group>
<NumberInput
label={t('pickupOffices.form.rescheduleCutoff', 'Reschedule minimum notice (hours)')}
min={0}
value={draft.rescheduleMinNoticeHours}
onChange={(v) =>
setDraft((d) => ({
...d,
rescheduleMinNoticeHours: Number(v) || d.rescheduleMinNoticeHours,
}))
}
/>
<Switch
label={t('pickupOffices.form.active', 'Active')}
checked={draft.isActive}
onChange={(e) => setDraft((d) => ({ ...d, isActive: e.currentTarget.checked }))}
/>
<ModalFooter>
<Button variant="default" onClick={close}>
{t('common.cancel', 'Cancel')}
</Button>
<Button loading={creating || updating} disabled={!draft.name.trim()} onClick={save}>
{t('common.save', 'Save')}
</Button>
</ModalFooter>
</Stack>
</Modal>
</Stack>
);
}
export default PickupOfficesPage;

View File

@@ -1,7 +1,7 @@
import { NumberInput, Text, TextInput } from '@mantine/core';
import { Badge, Group, NumberInput, Text, TextInput } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import type { QuestionBrief } from '../../../exam/types/exam';
import type { GradingSheetQuestion, QuestionBrief } from '../../../exam/types/exam';
export function recordResultColumns(
t: TFunction,
@@ -11,6 +11,9 @@ export function recordResultColumns(
questionRemarks: Record<string, string>;
onScoreChange: (questionId: string, value: number) => void;
onRemarkChange: (questionId: string, value: string) => void;
/** The candidate's own answer + auto-score, when available (empty for
* an OFFLINE candidate or one who hasn't sat an online attempt). */
answersByQuestion: Map<string, GradingSheetQuestion>;
},
): AdvancedColumn<QuestionBrief>[] {
return [
@@ -22,6 +25,20 @@ export function recordResultColumns(
</Text>
),
},
{
header: t('result.recordModal.candidateAnswer'),
cell: ({ row }) => {
const answer = handlers.answersByQuestion.get(row.original.id);
if (!answer || (!answer.answerText && !answer.selectedOptionText)) {
return <Text fz="xs" c="dimmed">{t('result.recordModal.noAnswer')}</Text>;
}
return (
<Text fz="sm" maw={220} lineClamp={3}>
{answer.selectedOptionText?.[locale] ?? answer.answerText}
</Text>
);
},
},
{
header: t('result.recordModal.maxPoints'),
cell: ({ row }) => (
@@ -32,16 +49,26 @@ export function recordResultColumns(
},
{
header: t('result.recordModal.score'),
cell: ({ row }) => (
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
min={0}
max={row.original.points}
size="xs"
style={{ width: 80 }}
/>
),
cell: ({ row }) => {
const autoGraded = handlers.answersByQuestion.get(row.original.id)?.autoScore != null;
return (
<Group gap={4} wrap="nowrap">
<NumberInput
value={handlers.scores[row.original.id] ?? 0}
onChange={(v) => handlers.onScoreChange(row.original.id, Number(v))}
min={0}
max={row.original.points}
size="xs"
style={{ width: 80 }}
/>
{autoGraded && (
<Badge size="xs" variant="light" color="teal">
{t('result.recordModal.autoGraded')}
</Badge>
)}
</Group>
);
},
},
{
header: t('result.recordModal.remark'),

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Modal,
@@ -20,7 +20,7 @@ import { extractErrorMessage } from '@ema-platform/api';
import { LICENSE_PERMISSIONS, RequirePermission } from '@ema-platform/auth';
import { recordResultColumns } from './columns';
import { useCreateResultMutation } from '../../api/result-api';
import { useGetExamRegistrationsQuery } from '../../../exam/api/exam-api';
import { useGetExamRegistrationsQuery, useGetGradingSheetQuery } from '../../../exam/api/exam-api';
import type { Exam } from '../../../exam/types/exam';
function InfoRow({ label, value }: { label: string; value: string }) {
@@ -55,12 +55,39 @@ export function RecordResultModal({
const { data: registrations } = useGetExamRegistrationsQuery(exam.id, {
skip: !opened,
});
// The candidate's own answers plus whatever score auto-grading could
// already compute for the CHOICE portion — degrades to "no data" for an
// OFFLINE candidate or one who never sat an online attempt, same as
// before this existed.
const { data: gradingSheet } = useGetGradingSheetQuery(
{ examId: exam.id, profileId: selectedSeafarerId ?? '' },
{ skip: !opened || !selectedSeafarerId },
);
const answersByQuestion = new Map(
(gradingSheet?.questions ?? []).map((q) => [q.questionId, q]),
);
const [createResult, { isLoading: isSaving }] = useCreateResultMutation();
const table = useServerTable();
const questions = exam.questions ?? [];
const pagedQuestions = table.paginate(questions);
// Prefill (never override) the CHOICE questions auto-grading already
// scored — the examiner only has to key in the ESSAY marks. A fresh
// seafarer selection always starts from an empty scores map, so this
// only ever fills in blanks, never stomps a manual edit already made.
useEffect(() => {
if (!gradingSheet) return;
const autoScores: Record<string, number> = {};
for (const q of gradingSheet.questions) {
if (q.autoScore !== null) autoScores[q.questionId] = q.autoScore;
}
if (Object.keys(autoScores).length) {
setScores((prev) => ({ ...autoScores, ...prev }));
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [gradingSheet]);
const seafarerOptions = (registrations ?? [])
.filter((registration) =>
['PRESENT', 'LATE'].includes(registration.attendanceStatus),
@@ -175,6 +202,7 @@ export function RecordResultModal({
questionRemarks,
onScoreChange: handleScoreChange,
onRemarkChange: handleQuestionRemarkChange,
answersByQuestion,
})}
data={pagedQuestions.rows}
itemCount={pagedQuestions.itemCount}

View File

@@ -5,10 +5,13 @@ import { IconSearch } from '@tabler/icons-react';
import { useDebouncedValue } from '@mantine/hooks';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
useListSeafarerDocumentsQuery,
type SeafarerDocumentKind,
type SeafarerDocumentRequestKind,
type SeafarerDocumentRow,
type SeafarerDocumentStatus,
} from '@ema-platform/api';
@@ -22,6 +25,10 @@ const STATUS_FILTERS = (
['PAYMENT_PENDING', 'PAID', 'PAYMENT_CONFIRMED', 'SCHEDULED', 'ISSUED', 'REJECTED'] as SeafarerDocumentStatus[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_STATUS_LABELS[value] }));
const REQUEST_KIND_FILTERS = (
['NEW', 'RENEWAL', 'REPLACEMENT'] as SeafarerDocumentRequestKind[]
).map((value) => ({ value, label: SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[value] }));
/**
* The Seaman Book queue and the BTC queue — one page, keyed by kind. Requests
* appear here once the seafarer registration that opened them is approved.
@@ -30,6 +37,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
const navigate = useNavigate();
const showDate = useDateDisplayer();
const [status, setStatus] = useState<SeafarerDocumentStatus | null>(null);
const [requestKind, setRequestKind] = useState<SeafarerDocumentRequestKind | null>(null);
const [search, setSearch] = useState('');
const [debouncedSearch] = useDebouncedValue(search, 300);
const [page, setPage] = useState(0);
@@ -38,6 +46,7 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
const { data, isLoading, isFetching, refetch } = useListSeafarerDocumentsQuery({
kind,
status: status ?? undefined,
requestKind: requestKind ?? undefined,
search: debouncedSearch || undefined,
take: pageSize,
skip: page * pageSize,
@@ -75,6 +84,15 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
</div>
),
},
{
header: 'Type',
accessorKey: 'requestKind',
cell: ({ row }) => (
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[row.original.requestKind]}>
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[row.original.requestKind]}
</Badge>
),
},
{
header: 'Fee',
accessorKey: 'feeAmount',
@@ -148,6 +166,17 @@ export function SeafarerDocumentQueuePage({ kind }: { kind: SeafarerDocumentKind
clearable
w={200}
/>
<Select
placeholder="All types"
data={REQUEST_KIND_FILTERS}
value={requestKind}
onChange={(v) => {
setRequestKind(v as SeafarerDocumentRequestKind | null);
setPage(0);
}}
clearable
w={160}
/>
</>
}
itemCount={data?.total ?? 0}

View File

@@ -4,6 +4,8 @@ import {Alert, Avatar, Badge, Button, Center, Container, Divider, Group, Loader,
import { IconAlertTriangle, IconArrowLeft, IconCash, IconCheck, IconDownload, IconFileCertificate } from '@tabler/icons-react';
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
@@ -133,6 +135,11 @@ export function SeafarerDocumentReviewPage() {
<Badge size="sm" variant="light" color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]}>
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
{document.requestKind !== 'NEW' && (
<Badge size="sm" variant="outline" color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]}>
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
</Badge>
)}
{document.documentNumber && (
<Badge size="sm" color="teal" leftSection={<IconCheck size={10} />}>
{document.documentNumber}

View File

@@ -1,10 +1,14 @@
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';
import { UserManagementApp } from '@tria-plc/iamui';
import type { DesignConfig, UserManagementSessionOptions } from '@tria-plc/iamui';
import '@tria-plc/iamui/style.css';
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";
import { UserManagementApp } from "@tria-plc/iamui";
import { BASE_API_URL } from "@ema-platform/api";
import type {
DesignConfig,
UserManagementSessionOptions,
} from "@tria-plc/iamui";
import "@tria-plc/iamui/style.css";
const UM_OVERRIDES = `
.um-theme-light {
@@ -58,73 +62,73 @@ const UM_OVERRIDES = `
const UM_CONFIG: DesignConfig = {
brand: {
appName: 'Ethiopian Maritime Licence',
logoUrl: '/assets/emaLogo.jpg',
appName: "Ethiopian Maritime Licence",
logoUrl: "/assets/emaLogo.jpg",
},
colors: {
primary: '#2563eb',
sidebar: '#ffffff',
background: '#f8fafc',
foreground: '#1e293b',
border: '#e2e8f0',
mutedForeground: '#94a3b8',
card: '#ffffff',
primary: "#2563eb",
sidebar: "#ffffff",
background: "#f8fafc",
foreground: "#1e293b",
border: "#e2e8f0",
mutedForeground: "#94a3b8",
card: "#ffffff",
},
typography: {
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
},
layout: {
userManagementView: 'classic',
sidebarBrandLabel: 'Ethiopian Maritime Authority',
sidebarBrandSublabel: 'User Management',
sidebarBackground: '#ffffff',
sidebarColor: '#1e293b',
sidebarMutedColor: '#94a3b8',
sidebarActiveBackground: '#eff6ff',
sidebarActiveColor: '#2563eb',
sidebarHoverBackground: '#f8fafc',
sidebarBorder: '#e2e8f0',
sidebarWidth: '280px',
sidebarCollapsedWidth: '80px',
modalAccentColor: '#2563eb',
modalHeaderBackground: '#f8fafc',
modalHeaderEditBackground: '#eff6ff',
modalIconBackground: '#eff6ff',
modalIconColor: '#2563eb',
modalTitleColor: '#1e293b',
modalFocusColor: '#2563eb',
modalSurface: '#ffffff',
userManagementView: "classic",
sidebarBrandLabel: "Ethiopian Maritime Authority",
sidebarBrandSublabel: "User Management",
sidebarBackground: "#ffffff",
sidebarColor: "#1e293b",
sidebarMutedColor: "#94a3b8",
sidebarActiveBackground: "#eff6ff",
sidebarActiveColor: "#2563eb",
sidebarHoverBackground: "#f8fafc",
sidebarBorder: "#e2e8f0",
sidebarWidth: "280px",
sidebarCollapsedWidth: "80px",
modalAccentColor: "#2563eb",
modalHeaderBackground: "#f8fafc",
modalHeaderEditBackground: "#eff6ff",
modalIconBackground: "#eff6ff",
modalIconColor: "#2563eb",
modalTitleColor: "#1e293b",
modalFocusColor: "#2563eb",
modalSurface: "#ffffff",
},
};
const UM_RUNTIME = {
basename: '/um',
basename: "/um",
// Keep the embedded IAM module on the same API as the backoffice client.
// When VITE_BASE_API_URL is absent locally, passing undefined makes iamui
// fall back to its remote development server, where the local JWT is
// rejected and the module redirects to its login page.
apiUrl: import.meta.env.VITE_BASE_API_URL ?? 'http://localhost:3000/api',
apiUrl: import.meta.env.VITE_BASE_API_URL ?? "http://localhost:3000/api",
};
const buttonStyle: React.CSSProperties = {
position: 'fixed',
position: "fixed",
top: 12,
left: 12,
zIndex: 9999,
display: 'flex',
alignItems: 'center',
display: "flex",
alignItems: "center",
gap: 6,
padding: '8px 16px',
border: '1px solid #e2e8f0',
padding: "8px 16px",
border: "1px solid #e2e8f0",
borderRadius: 8,
background: '#ffffff',
color: '#2563eb',
background: "#ffffff",
color: "#2563eb",
fontSize: 14,
fontWeight: 600,
cursor: 'pointer',
fontFamily: 'Inter, ui-sans-serif, system-ui, sans-serif',
boxShadow: '0 1px 3px rgba(0,0,0,0.08)',
transition: 'all 150ms ease',
cursor: "pointer",
fontFamily: "Inter, ui-sans-serif, system-ui, sans-serif",
boxShadow: "0 1px 3px rgba(0,0,0,0.08)",
transition: "all 150ms ease",
};
export default function UserManagementPage() {
@@ -133,29 +137,31 @@ export default function UserManagementPage() {
const navigate = useNavigate();
const handleReturn = useCallback(() => {
navigate('/dashboard');
navigate("/dashboard");
}, [navigate]);
useEffect(() => {
if (!containerRef.current) return;
const style = document.createElement('style');
const style = document.createElement("style");
style.textContent = UM_OVERRIDES;
document.head.appendChild(style);
const token = Cookies.get('ema-backoffice-auth-token') ?? '';
const refreshToken = Cookies.get('ema-backoffice-refresh-token');
const token = Cookies.get("ema-backoffice-auth-token") ?? "";
const refreshToken = Cookies.get("ema-backoffice-refresh-token");
const session: UserManagementSessionOptions = {
initialSession: token
? { token, refreshToken, rememberMe: true }
: null,
initialSession: token ? { token, refreshToken, rememberMe: true } : null,
enableEmbeddedAuthBridge: false,
};
rootRef.current = createRoot(containerRef.current);
rootRef.current.render(
<UserManagementApp config={UM_CONFIG} runtime={UM_RUNTIME} session={session} />,
<UserManagementApp
config={UM_CONFIG}
runtime={UM_RUNTIME}
session={session}
/>,
);
return () => {
@@ -173,21 +179,28 @@ export default function UserManagementPage() {
onClick={handleReturn}
style={buttonStyle}
onMouseEnter={(e) => {
e.currentTarget.style.background = '#f8fafc';
e.currentTarget.style.boxShadow = '0 1px 6px rgba(0,0,0,0.12)';
e.currentTarget.style.background = "#f8fafc";
e.currentTarget.style.boxShadow = "0 1px 6px rgba(0,0,0,0.12)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = '#ffffff';
e.currentTarget.style.boxShadow = '0 1px 3px rgba(0,0,0,0.08)';
}}
>
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
e.currentTarget.style.background = "#ffffff";
e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)";
}}>
<svg
width="16"
height="16"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round">
<path d="m12 19-7-7 7-7" />
<path d="M19 12H5" />
</svg>
Return to EMA
</button>
<div ref={containerRef} style={{ position: 'fixed', inset: 0 }} />
<div ref={containerRef} style={{ position: "fixed", inset: 0 }} />
</>
);
}

View File

@@ -99,6 +99,8 @@ export const am: Translations = {
seafarerRegistrationQueue: "የመርከበኞች ምዝገባ ወረፋ",
applications: "ማመልከቻዎች",
paymentConfig: "የክፍያ ውቅረት",
pickupDesk: "የመረከቢያ ዴስክ",
pickupOffices: "የመረከቢያ ቢሮዎች",
analytics: "ትንታኔ",
seaServiceVerification: "የባህር አገልግሎት ማረጋገጫ",
medicalVerification: "የሕክምና ማረጋገጫ",
@@ -263,7 +265,6 @@ export const am: Translations = {
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -275,6 +276,8 @@ export const am: Translations = {
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
next: "ቀጣይ",
back: "ተመለስ",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
@@ -391,6 +394,9 @@ export const am: Translations = {
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
paperLocked: "ወረቀቱ ተቆልፏል",
paperLockedHint:
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
},
country: {
@@ -683,6 +689,9 @@ export const am: Translations = {
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
scorePerQuestion: "በጥያቄ ውጤት",
question: "ጥያቄ",
candidateAnswer: "የተፈታኙ መልስ",
noAnswer: "የተመዘገበ መልስ የለም",
autoGraded: "በራስ-ሰር የተመዘነ",
maxPoints: "ከፍተኛ ውጤት",
score: "ውጤት",
remark: "ማስታወሻ",
@@ -887,6 +896,12 @@ export const am: Translations = {
type: "ዓይነት",
anyType: "ማንኛውም",
typeCol: "ዓይነት",
kind: "የማመልከቻ ዓይነት",
kindValues: {
NEW: "አዲስ",
RENEWAL: "እድሳት",
REISSUE: "ምትክ",
},
statusCol: "ሁኔታ",
statusValues: {
DRAFT: "ረቂቅ",
@@ -962,6 +977,7 @@ export const am: Translations = {
},
review: {
certificateSuperseded: "ሰርተፍኬቱ ተተክቷል",
summary: "ማጠቃለያ",
officer: "ሹም",
supervisor: "የበላይ ኃላፊ",

View File

@@ -98,6 +98,8 @@ export const en = {
seafarerRegistrationQueue: 'Seafarer Registration Queue',
applications: 'Applications',
paymentConfig: 'Payment Config',
pickupDesk: 'Pickup Desk',
pickupOffices: 'Pickup Offices',
analytics: 'Analytics',
seaServiceVerification: 'Sea Service Verification',
medicalVerification: 'Medical Verification',
@@ -262,7 +264,6 @@ export const en = {
both: 'Both',
offline: 'Offline',
online: 'Online',
onlineChoiceOnlyHint: 'Online exams are graded automatically, which only works for multiple choice.',
sum: 'Sum',
average: 'Average',
percentage: 'Percentage',
@@ -275,6 +276,8 @@ export const en = {
fillRequiredBasic: 'Please fill all required fields in Basic Info.',
fillRequiredSettings: 'Please fill all required fields in Settings — type, form, administration method, evaluation method, and cutting point.',
directionBothLanguages: 'Direction needs text in both English and Amharic, or leave both empty.',
next: 'Next',
back: 'Back',
status: 'Status',
statusPlaceholder: 'Exam status',
pending: 'Pending',
@@ -390,6 +393,9 @@ export const en = {
'Not enough approved questions in the bank for this subject.',
cannotReachCuttingPoint:
'This paper cannot reach the passing mark (max {{max}}, pass mark {{cuttingPoint}}). Add more/higher-point questions, or lower the cutting point.',
paperLocked: 'Paper locked',
paperLockedHint:
'{{count}} candidate(s) have registered for this session. Every candidate must sit the same paper, so questions can no longer be changed.',
},
country: {
@@ -684,6 +690,9 @@ export const en = {
seafarerPlaceholder: 'Search and select a seafarer',
scorePerQuestion: 'Score per Question',
question: 'Question',
candidateAnswer: "Candidate's Answer",
noAnswer: 'No answer on file',
autoGraded: 'Auto-graded',
maxPoints: 'Max Points',
score: 'Score',
remark: 'Remark',
@@ -895,6 +904,12 @@ export const en = {
type: 'Type',
anyType: 'Any',
typeCol: 'Type',
kind: 'Application kind',
kindValues: {
NEW: 'New',
RENEWAL: 'Renewal',
REISSUE: 'Replacement',
},
statusCol: 'Status',
statusValues: {
DRAFT: 'Draft',
@@ -972,6 +987,7 @@ export const en = {
},
review: {
certificateSuperseded: 'Certificate superseded',
summary: 'Summary',
officer: 'Officer',
supervisor: 'Supervisor',

View File

@@ -2,6 +2,8 @@ import {
IconAnchor,
IconArrowsExchange,
IconBook2,
IconBuildingWarehouse,
IconCalendarEvent,
IconChartBar,
IconClipboardList,
IconClipboardText,
@@ -315,6 +317,18 @@ export const NAV_SECTIONS: NavSection[] = [
icon: IconCreditCard,
permissions: [P.VIEW_PAYMENTS],
},
{
to: '/pickup-desk',
label: 'nav.pickupDesk',
icon: IconCalendarEvent,
permissions: [P.MANAGE_PICKUP_DESK],
},
{
to: '/pickup-offices',
label: 'nav.pickupOffices',
icon: IconBuildingWarehouse,
permissions: [P.CONFIGURE_PICKUP_OFFICES],
},
],
},
{

View File

@@ -28,6 +28,8 @@ import {
SeaServiceVerificationPage,
} from '../features/medical-verification/pages/MedicalVerificationPage';
import { PaymentConfigPage } from '../features/payment-config/pages/PaymentConfigPage';
import { PickupDeskPage } from '../features/pickup/pages/PickupDeskPage';
import { PickupOfficesPage } from '../features/pickup/pages/PickupOfficesPage';
import { SeafarerRegistryPage } from '../features/seafarer-registry/pages/SeafarerRegistryPage';
import { SeafarerRegistrationQueuePage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationQueuePage';
import { SeafarerRegistrationReviewPage } from '../features/seafarer-registration-review/pages/SeafarerRegistrationReviewPage';
@@ -106,6 +108,8 @@ const router = createBrowserRouter([
{ path: 'medical-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <MedicalVerificationPage />) },
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], <PickupDeskPage />) },
{ path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], <PickupOfficesPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
// Seafarer registration is not a licence: own queue, own review.

View File

@@ -17,7 +17,7 @@ export default defineConfig({
// port: 4201,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },

View File

@@ -22,7 +22,7 @@ own ports, against its own database:
| Backoffice | 4303 | same |
| Database | — | `ema_e2e` |
This is deliberate. A developer's stack is usually already up on 3000/4200/4201,
This is deliberate. A developer's stack is usually already up on 3000/3001/4201,
and `dev/start.sh` **rewrites** `emaapi/apps/server/emaapi/.env` and the apps'
`.env.local` on every run — a suite that read those files would point at
whichever stack was started last. The API is launched with `DATABASE_NAME`,

View File

@@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string {
});
}
const API_BASE =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL as API_BASE } from '@ema-platform/api';
async function generateCertificate(profileId: string): Promise<Blob> {
const token = authStorage.getToken();

View File

@@ -9,6 +9,9 @@ import type {
SaveState,
} from '../types/exam-attempt';
/** Mirrors the server's allow-list (ExamAttemptService.MAY_SIT). */
const MAY_SIT = ['PRESENT', 'LATE'];
const ESSAY_DEBOUNCE_MS = 1500;
type ViewState = 'loading' | 'not-started' | 'taking' | 'completed' | 'error';
@@ -83,6 +86,19 @@ export function useExamAttempt(examId: string | undefined) {
setErrorMessage('You are not registered for this examination.');
return;
}
// Attendance gates the sitting, and the exam list already hides "Take
// exam" for these — this is the direct-link path. The server refuses
// either way (ExamAttemptService.MAY_SIT); this only makes the refusal
// legible instead of a raw error key on a Start button that never works.
if (!MAY_SIT.includes(registration.attendanceStatus)) {
setViewState('error');
setErrorMessage(
registration.attendanceStatus === 'REGISTERED'
? 'An invigilator must confirm you are present before this exam opens.'
: 'Your attendance record does not permit sitting this examination.',
);
return;
}
if (mineData) {
seedFrom(mineData);
return;
@@ -200,7 +216,14 @@ export function useExamAttempt(examId: string | undefined) {
}).unwrap();
seedFrom(result);
} catch (error) {
notify.error(extractErrorMessage(error, 'Could not start the exam.'));
const key = extractErrorMessage(error, 'Could not start the exam.');
notify.error(
key === 'attendance_not_confirmed'
? 'An invigilator must confirm you are present before this exam opens.'
: key === 'candidate_not_present'
? 'Your attendance record does not permit sitting this examination.'
: key,
);
}
}, [examId, startTrigger, seedFrom]);

View File

@@ -1,4 +1,4 @@
import { Badge, Button, Text } from '@mantine/core';
import { Badge, Button, Text, Tooltip } from '@mantine/core';
import { IconFileText, IconGavel, IconPlayerPlay } from '@tabler/icons-react';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
@@ -20,7 +20,14 @@ const ATTENDANCE_COLOR: Record<AttendanceStatus, string> = {
DISQUALIFIED: 'red',
};
const NOT_SITTING: AttendanceStatus[] = ['ABSENT', 'WITHDRAWN', 'DISQUALIFIED'];
/**
* Attendance rulings that permit sitting the paper — an allow-list mirroring
* the server's (ExamAttemptService.MAY_SIT). The deny-list this replaced named
* only ABSENT/WITHDRAWN/DISQUALIFIED, so the default REGISTERED fell through
* and "Take exam" appeared before any invigilator had confirmed the candidate
* was there. LATE counts: a late arrival is present, just not on time.
*/
const MAY_SIT: AttendanceStatus[] = ['PRESENT', 'LATE'];
export function registrationColumns(
t: TFunction,
@@ -115,9 +122,26 @@ export function registrationColumns(
</Badge>
);
}
const eligible =
exam?.status === 'ACTIVE' && !NOT_SITTING.includes(row.original.attendanceStatus);
if (!eligible || !deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
if (!deps.can([PORTAL_PERMISSIONS.APPLY_EXAM])) return null;
// Say why the exam is shut rather than rendering an empty cell: the
// candidate is waiting on an invigilator, and silence reads as a bug.
if (row.original.attendanceStatus === 'REGISTERED') {
return (
<Tooltip label={t('exams.columns.awaitingAttendanceHint')} multiline w={240}>
<Badge size="sm" variant="light" color="gray">
{t('exams.columns.awaitingAttendance')}
</Badge>
</Tooltip>
);
}
if (!MAY_SIT.includes(row.original.attendanceStatus)) {
return (
<Badge size="sm" variant="light" color="orange">
{t('exams.columns.notSitting')}
</Badge>
);
}
if (exam?.status !== 'ACTIVE') return null;
return (
<Button
size="compact-xs"

View File

@@ -10,7 +10,7 @@ import {
Text,
Tooltip,
} from '@mantine/core';
import { IconDownload, IconRefresh } from '@tabler/icons-react';
import { IconAlertTriangle, IconDownload, IconRefresh } from '@tabler/icons-react';
import {
extractErrorMessage,
useLocalized,
@@ -59,6 +59,36 @@ export function useRenewLicense() {
return { renewLicense, isRenewing };
}
/**
* Damaged/Reissue reuses the same wizard, application kind REISSUE — the
* "Damage Information" step and the Reissue document set only appear because
* the created application carries that kind, exactly the way RENEWAL's own
* fields do above.
*/
export function useReissueLicense() {
const navigate = useNavigate();
const { t } = useTranslation();
const [createApplication, { isLoading: isReissuing }] =
useCreateApplicationMutation();
async function reissueLicense(license: IssuedLicense) {
const typeKey = license.licenseType?.key;
if (!typeKey) return;
try {
const application = await createApplication({
licenseType: typeKey,
kind: 'REISSUE',
previousLicenseId: license.id,
}).unwrap();
navigate(`/licensing/${typeKey}/applications/${application.id}`);
} catch (err) {
notify.error(extractErrorMessage(err), t('licensing.card.reissueFailed'));
}
}
return { reissueLicense, isReissuing };
}
function daysUntil(date: string): number {
const ms = new Date(date).getTime() - Date.now();
return Math.ceil(ms / 86_400_000);
@@ -68,14 +98,18 @@ export function LicenseCard({
license,
isDownloading,
isRenewing,
isReissuing,
onDownload,
onRenew,
onReissue,
}: {
license: IssuedLicense;
isDownloading: boolean;
isRenewing: boolean;
isReissuing?: boolean;
onDownload: () => void;
onRenew: () => void;
onReissue?: () => void;
}) {
// The API computes both in the authority's timezone; the local fallbacks are
// only for a cached response from before those fields existed.
@@ -87,6 +121,7 @@ export function LicenseCard({
// badge.
const current = license.status === 'ACTIVE' && !expired;
const renewable = license.renewable ?? false;
const reissuable = license.reissuable ?? false;
const showDate = useDateDisplayer();
const localized = useLocalized();
const { t } = useTranslation();
@@ -179,6 +214,25 @@ export function LicenseCard({
</Button>
</RequirePermission>
)}
{/* Damaged/Reissue has no window — a lost or damaged document can be
replaced at any point in its validity, unlike Renewal above. */}
{reissuable && onReissue && (
<RequirePermission anyOf={[LICENSE_PERMISSIONS.CREATE_APPLICATION]} hideOnly>
<Button
fullWidth
mt="xs"
size="xs"
variant="subtle"
color="gray"
loading={isReissuing}
leftSection={<IconAlertTriangle size={14} />}
onClick={onReissue}
>
{t('licensing.card.reportDamaged')}
</Button>
</RequirePermission>
)}
</Card>
);
}

View File

@@ -0,0 +1,54 @@
import { Group, Paper, Stack, Text } from '@mantine/core';
import { IconCalendarEvent } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
import type { IssuancePeriod } from '@ema-platform/api';
/**
* Read-only view of the pickup appointment a team leader assigned (spec
* §19-21 — the office decides who comes in when, the applicant doesn't pick
* a slot). Shown once payment is confirmed and the licence type prints once
* and hands the document over in person.
*/
export function PickupSchedulingPanel({
scheduledDate,
scheduledPeriod,
}: {
scheduledDate: string | null;
scheduledPeriod: IssuancePeriod | null;
}) {
const { t } = useTranslation();
return (
<Paper withBorder p="md" radius="md">
<Group gap="xs" mb="sm">
<IconCalendarEvent size={16} />
<Text fw={600} size="sm">
{t('pickup.title')}
</Text>
</Group>
{scheduledDate ? (
<Stack gap={4}>
<Text size="sm">
{t('pickup.scheduledFor', {
date: scheduledDate,
period:
scheduledPeriod === 'AFTERNOON'
? t('pickup.afternoon')
: t('pickup.morning'),
})}
</Text>
<Text size="sm" c="dimmed">
{t('pickup.setByOffice')}
</Text>
</Stack>
) : (
<Text size="sm" c="dimmed">
{t('pickup.awaitingSchedule')}
</Text>
)}
</Paper>
);
}
export default PickupSchedulingPanel;

View File

@@ -4,6 +4,7 @@ import {
ActionIcon,
Alert,
Badge,
Box,
Button,
Card,
Container,
@@ -67,6 +68,7 @@ import {
PORTAL_PERMISSIONS,
RequirePermission,
useCurrentProfile,
usePermissions,
} from "@ema-platform/auth";
import { ApplicationSummary } from "../components/ApplicationSummary";
import {
@@ -74,6 +76,7 @@ import {
fillFromVessel,
} from "../components/ConfigDrivenSection";
import { DocumentSlots } from "../components/DocumentSlots";
import { PickupSchedulingPanel } from "../components/PickupSchedulingPanel";
import { StaffEvidence } from "../components/StaffEvidence";
import { useAppSelector } from "../../../store/hooks";
import { AdvancedTable, useServerTable, PageLoader } from '@ema-platform/ui';
@@ -117,9 +120,14 @@ export function LicenseApplicationPage() {
const { data: config, isLoading: loadingConfig } =
useGetLicenseTypeRequirementsQuery({ idOrKey: typeCode });
const { profile } = useCurrentProfile();
const { can: hasPermission, known: permissionsKnown } = usePermissions();
// Only the vessel-select field (ConfigDrivenSection) reads this — fetched
// here rather than deeper down since it's the shared source of draft state.
const { data: vessels } = useGetMyVesselsQuery();
// Skipped for accounts without VIEW_OWN_VESSELS (e.g. freight forwarders):
// the API 403s for them, since vessels belong to VESSEL_OWNER accounts.
const { data: vessels } = useGetMyVesselsQuery(undefined, {
skip: !permissionsKnown || !hasPermission([PORTAL_PERMISSIONS.VIEW_OWN_VESSELS]),
});
const [createApplication] = useCreateApplicationMutation();
const [appId, setAppId] = useState<string | undefined>(applicationId);
@@ -394,14 +402,20 @@ export function LicenseApplicationPage() {
const staffLocked = roundIsItemised && !hasStaffRemarks;
// Sections that share a group collapse onto one step, so the stepper stays
// short instead of showing a page per section.
// short instead of showing a page per section. A Damaged/Reissue
// application skips Staff and Documents outright — it asks nothing beyond
// the Damage Information step, regardless of what the licence type
// otherwise requires for a new application or renewal.
const isReissue = application?.kind === 'REISSUE';
const steps = useMemo(
() =>
buildWizardSteps(config?.licenseType?.formSchema?.sections ?? [], draft, {
hasStaff: (config?.staffRoleRequirements?.length ?? 0) > 0,
hasStaff: isReissue ? false : (config?.staffRoleRequirements?.length ?? 0) > 0,
hasDocuments: !isReissue,
language: i18n.language,
applicationKind: application?.kind,
}),
[config, draft, i18n.language],
[config, draft, i18n.language, application?.kind, isReissue],
);
const sections = useMemo(
() => steps.flatMap((step) => step.sections),
@@ -720,6 +734,11 @@ export function LicenseApplicationPage() {
>
{STATUS_LABELS[application.status]}
</Badge>
{detail?.issuedLicenseStatus === "SUPERSEDED" && (
<Badge size="sm" variant="light" color="gray">
{t("licensing.certificateSuperseded", "Certificate superseded")}
</Badge>
)}
</Group>
</div>
<Group gap="md" align="center">
@@ -759,6 +778,17 @@ export function LicenseApplicationPage() {
</Alert>
)}
{config.licenseType.requiresIssuanceScheduling &&
(application.status === "PAYMENT_CONFIRMED" ||
application.status === "SCHEDULED") && (
<Box mb="md">
<PickupSchedulingPanel
scheduledDate={application.scheduledIssuanceDate}
scheduledPeriod={application.scheduledIssuancePeriod}
/>
</Box>
)}
{showSummary && editableWhileSubmitted && (
<Alert
color="blue"

View File

@@ -1,4 +1,4 @@
import { Badge, Box, Progress, Text } from '@mantine/core';
import { Badge, Box, Group, Progress, Text } from '@mantine/core';
import type { TFunction } from 'i18next';
import type { AdvancedColumn } from '@ema-platform/ui';
import {
@@ -6,10 +6,23 @@ import {
STATUS_PROGRESS,
applicantOrCompanyName,
localized,
type ApplicationKind,
type LicenseApplication,
type LicenseStatus,
} from '@ema-platform/api';
const KIND_LABEL: Record<ApplicationKind, string> = {
NEW: 'applications.table.kindNew',
RENEWAL: 'applications.table.kindRenewal',
REISSUE: 'applications.table.kindReissue',
};
const KIND_COLOR: Record<ApplicationKind, string> = {
NEW: 'blue',
RENEWAL: 'teal',
REISSUE: 'orange',
};
export function applicationColumns(
t: TFunction,
deps: {
@@ -23,9 +36,16 @@ export function applicationColumns(
header: t('applications.table.licence'),
cell: ({ row }) => (
<Box>
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
<Group gap={6} wrap="nowrap">
<Text size="sm" fw={600}>
{localized(row.original.licenseType?.name, deps.language) || '—'}
</Text>
{row.original.kind !== 'NEW' && (
<Badge size="xs" variant="light" color={KIND_COLOR[row.original.kind]}>
{t(KIND_LABEL[row.original.kind])}
</Badge>
)}
</Group>
<Text size="xs" c="dimmed">
{row.original.applicationNumber}
</Text>

View File

@@ -32,7 +32,7 @@ import {
import { AdvancedTable, AmharicDatePicker, EmptyState, useServerTable } from '@ema-platform/ui';
import { useDateDisplayer } from '@ema-platform/shared';
import { LicenseCatalogue } from '../../components/LicenseCatalogue';
import { LicenseCard, useRenewLicense } from '../../components/LicenseCard';
import { LicenseCard, useReissueLicense, useRenewLicense } from '../../components/LicenseCard';
import { useApplicationPayment } from '../../../payments/hooks/useApplicationPayment';
import { notifications } from '@mantine/notifications';
import {
@@ -47,6 +47,7 @@ import {
useGetMyLicensesQuery,
useGetPaymentCapabilitiesQuery,
useRetakeExamMutation,
type ApplicationKind,
type LicenseStatus,
} from '@ema-platform/api';
import {
@@ -102,6 +103,7 @@ export function MyApplicationsPage() {
const [retakeExam, { isLoading: requestingExamFee }] = useRetakeExamMutation();
const [getCertificateUrl] = useGetCertificateUrlMutation();
const { renewLicense, isRenewing } = useRenewLicense();
const { reissueLicense, isReissuing } = useReissueLicense();
const [isDownloadingCert, setIsDownloadingCert] = useState(false);
const { can } = usePermissions();
@@ -207,10 +209,13 @@ export function MyApplicationsPage() {
const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState<LicenseStatus | null>(null);
const [kindFilter, setKindFilter] = useState<ApplicationKind | null>(null);
const [dateFrom, setDateFrom] = useState('');
const [dateTo, setDateTo] = useState('');
const [bucketFilter, setBucketFilter] = useState<Bucket>(null);
const hasFilters = Boolean(search || statusFilter || dateFrom || dateTo || bucketFilter);
const hasFilters = Boolean(
search || statusFilter || kindFilter || dateFrom || dateTo || bucketFilter,
);
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
const counts = useMemo(() => {
@@ -228,6 +233,7 @@ export function MyApplicationsPage() {
if (!haystack.includes(q)) return false;
}
if (statusFilter && app.status !== statusFilter) return false;
if (kindFilter && app.kind !== kindFilter) return false;
// Drafts have no submittedAt, so date filtering falls back to createdAt
// rather than silently excluding every draft from a date-ranged search.
const at = app.submittedAt ?? app.createdAt;
@@ -245,13 +251,14 @@ export function MyApplicationsPage() {
const bAt = b.submittedAt ?? b.createdAt;
return aAt < bAt ? 1 : aAt > bAt ? -1 : 0;
});
}, [allItems, search, statusFilter, dateFrom, dateTo, bucketFilter]);
}, [allItems, search, statusFilter, kindFilter, dateFrom, dateTo, bucketFilter]);
const page = paginate(items);
function clearFilters() {
setSearch('');
setStatusFilter(null);
setKindFilter(null);
setDateFrom('');
setDateTo('');
setBucketFilter(null);
@@ -385,6 +392,22 @@ export function MyApplicationsPage() {
clearable
w={200}
/>
<Select
label={t('applications.filters.kind')}
placeholder={t('applications.filters.any')}
data={[
{ value: 'NEW', label: t('applications.table.kindNew') },
{ value: 'RENEWAL', label: t('applications.table.kindRenewal') },
{ value: 'REISSUE', label: t('applications.table.kindReissue') },
]}
value={kindFilter}
onChange={(v) => {
setKindFilter(v as ApplicationKind | null);
setPageIndex(0);
}}
clearable
w={160}
/>
<AmharicDatePicker
label={t('applications.filters.from')}
value={dateFrom}
@@ -475,8 +498,10 @@ export function MyApplicationsPage() {
license={license}
isDownloading={isDownloadingCert}
isRenewing={isRenewing}
isReissuing={isReissuing}
onDownload={() => downloadCertificate(license.id)}
onRenew={() => renewLicense(license)}
onReissue={() => reissueLicense(license)}
/>
))}
</SimpleGrid>

View File

@@ -22,11 +22,15 @@ import {
IconFileDescription,
IconInfoCircle,
IconPrinter,
IconRefresh,
IconReplace,
IconShield,
} from "@tabler/icons-react";
import { notifications } from "@mantine/notifications";
import {
SEAFARER_DOCUMENT_KIND_LABELS,
SEAFARER_DOCUMENT_REQUEST_KIND_COLORS,
SEAFARER_DOCUMENT_REQUEST_KIND_LABELS,
SEAFARER_DOCUMENT_STATUS_COLORS,
SEAFARER_DOCUMENT_STATUS_LABELS,
extractErrorMessage,
@@ -34,6 +38,8 @@ import {
useGetMySeafarerDocumentsQuery,
useGetPaymentCapabilitiesQuery,
useLazyGetMySeafarerDocumentDownloadQuery,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
type SeafarerDocument,
type SeafarerDocumentStatus,
} from "@ema-platform/api";
@@ -73,9 +79,20 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypass, { isLoading: bypassing }] = useBypassDocumentPaymentMutation();
const [getDownload, { isFetching: downloading }] = useLazyGetMySeafarerDocumentDownloadQuery();
const [renew, { isLoading: renewing }] = useRenewSeafarerDocumentMutation();
const [replaceDoc, { isLoading: replacing }] = useReplaceSeafarerDocumentMutation();
const title = SEAFARER_DOCUMENT_KIND_LABELS[document.kind];
const activeStep = stageIndexFor(document.status);
async function renewOrReplace(action: () => ReturnType<typeof renew>) {
try {
await action().unwrap();
onChanged();
} catch (err) {
notifications.show({ color: "red", title: "Request failed", message: extractErrorMessage(err) });
}
}
async function download() {
try {
const { url } = await getDownload(document.id).unwrap();
@@ -102,9 +119,16 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
</Text>
</div>
</Group>
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
<Group gap="xs">
{document.requestKind !== "NEW" && (
<Badge color={SEAFARER_DOCUMENT_REQUEST_KIND_COLORS[document.requestKind]} variant="outline" size="lg">
{SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]}
</Badge>
)}
<Badge color={SEAFARER_DOCUMENT_STATUS_COLORS[document.status]} variant="light" size="lg">
{SEAFARER_DOCUMENT_STATUS_LABELS[document.status]}
</Badge>
</Group>
</Group>
{document.status !== "REJECTED" && document.status !== "CANCELLED" && (
@@ -165,9 +189,29 @@ function DocumentCard({ document, onChanged }: { document: SeafarerDocument; onC
{document.issueDate && ` on ${formatDate(document.issueDate)}`}
{document.expiryDate && `, valid until ${formatDate(document.expiryDate)}`}.
</span>
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Group gap="xs">
<Button size="xs" variant="light" leftSection={<IconDownload size={14} />} loading={downloading} onClick={download}>
Download PDF
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconRefresh size={14} />}
loading={renewing}
onClick={() => renewOrReplace(() => renew(document.id))}
>
Renew
</Button>
<Button
size="xs"
variant="default"
leftSection={<IconReplace size={14} />}
loading={replacing}
onClick={() => renewOrReplace(() => replaceDoc(document.id))}
>
Report Lost/Damaged
</Button>
</Group>
</Group>
</Alert>
)}

View File

@@ -193,6 +193,7 @@ export const am: Translations = {
search: 'ፈልግ',
searchPlaceholder: 'ቁጥር ወይም አመልካች',
status: 'ሁኔታ',
kind: 'ዓይነት',
any: 'ማንኛውም',
from: 'ከ',
to: 'እስከ',
@@ -216,6 +217,9 @@ export const am: Translations = {
applicant: 'አመልካች',
progress: 'ደረጃ',
applicationNumber: 'የማመልከቻ ቁጥር',
kindNew: 'አዲስ',
kindRenewal: 'እድሳት',
kindReissue: 'ምትክ',
},
actions: {
continue: 'ቀጥል',
@@ -626,6 +630,28 @@ export const am: Translations = {
createOne: "አንድ ይፍጠሩ",
},
fayda: {
continueWith: "በፋይዳ ይቀጥሉ",
orFillManually: "ወይም መረጃዎን ራስዎ ይሙሉ",
verifiedTitle: "በፋይዳ ተረጋግጧል",
verifiedBody: "ፋይዳ ያረጋገጣቸውን መረጃዎች ሞልተናል። እባክዎ የቀሩትን መስኮች ያሟሉ።",
discard: "እነዚህን መረጃዎች አጥፍቼ ቅጹን ራሴ እሞላለሁ",
fieldVerified: "ከፋይዳ",
fieldConflict: "በሌላ መለያ ተይዟል",
conflictBody:
"አንዳንድ የተረጋገጡ መረጃዎች አስቀድሞ የሌላ መለያ ናቸው። የተመለከቱትን መስኮች ይቀይሩ ወይም ይግቡ።",
brandTitle: "በፋይዳ በማረጋገጥ ላይ",
brandSubtitle: "ማንነትዎን እስክናረጋግጥ ድረስ አንድ አፍታ።",
verifying: "የፋይዳ ማንነትዎን በማረጋገጥ ላይ…",
failedTitle: "ማረጋገጡ አልተጠናቀቀም",
backToSignup: "ወደ ምዝገባ ተመለስ",
cancelled: "የፋይዳ ማረጋገጫው ተሰርዟል። አሁንም በእጅ መመዝገብ ይችላሉ።",
rejected: "ፋይዳ ማንነትዎን ማረጋገጥ አልቻለም። እባክዎ እንደገና ይሞክሩ።",
invalidCallback: "ይህ የማረጋገጫ ሊንክ አልተሟላም። እባክዎ እንደገና ይጀምሩ።",
sessionLost: "የማረጋገጫ ክፍለ ጊዜዎ አልፏል። እባክዎ እንደገና ይጀምሩ።",
stateMismatch: "ይህ ማረጋገጫ ሊታመን አልቻለም። እባክዎ እንደገና ይጀምሩ።",
},
signup: {
usernameMinLength: "የተጠቃሚ ስም ቢያንስ 3 ቁምፊዎች ሊኖረው ይገባል",
nameEnRequired: "ስም (እንግሊዝኛ) ያስፈልጋል",
@@ -801,6 +827,7 @@ export const am: Translations = {
},
licensing: {
certificateSuperseded: 'ሰርተፍኬቱ ተተክቷል',
vesselPicker: {
placeholder: 'የተመዘገበ መርከብ ይምረጡ',
},
@@ -825,6 +852,8 @@ export const am: Translations = {
renewDays_one: 'አድስ — በ{{count}} ቀን ውስጥ ያበቃል',
renewDays_other: 'አድስ — በ{{count}} ቀናት ውስጥ ያበቃል',
renewFailed: 'ዕድሳት መጀመር አልተቻለም',
reportDamaged: 'ጉዳት ሪፖርት ያድርጉ / ምትክ ይጠይቁ',
reissueFailed: 'የምትክ ጥያቄ መጀመር አልተቻለም',
status: {
ACTIVE: 'የፀና',
EXPIRED: 'ጊዜው ያለፈበት',
@@ -859,6 +888,15 @@ export const am: Translations = {
},
},
pickup: {
title: 'የሰነድ መረከቢያ',
scheduledFor: 'ሰነድዎን ለመረከብ በ{{date}} ({{period}}) ወደ ቢሮ ይምጡ።',
setByOffice: 'ይህ ቀጠሮ በፈቃድ ጽ/ቤቱ ተይዟል።',
awaitingSchedule: 'ክፍያዎ ከተረጋገጠ በኋላ ፈቃድ ጽ/ቤቱ የመረከቢያ ቀን ይይዝልዎታል።',
morning: 'ጠዋት',
afternoon: 'ከሰዓት በኋላ',
},
certificates: {
title: "የእኔ የምስክር ወረቀቶች",
loading: "የምስክር ወረቀቶች በመጫን ላይ…",
@@ -1012,6 +1050,10 @@ export const am: Translations = {
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
awaitingAttendanceHint:
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
notSitting: 'አይፈተኑም',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',

View File

@@ -193,6 +193,7 @@ export const en = {
search: 'Search',
searchPlaceholder: 'Number or applicant',
status: 'Status',
kind: 'Type',
any: 'Any',
from: 'From',
to: 'To',
@@ -216,6 +217,9 @@ export const en = {
applicant: 'Applicant',
progress: 'Progress',
applicationNumber: 'Application №',
kindNew: 'New',
kindRenewal: 'Renewal',
kindReissue: 'Replacement',
},
actions: {
continue: 'Continue',
@@ -627,6 +631,28 @@ export const en = {
createOne: 'Create one',
},
fayda: {
continueWith: 'Continue with Fayda',
orFillManually: 'or fill in your details',
verifiedTitle: 'Verified with Fayda',
verifiedBody: 'We filled in the details Fayda confirmed. Please complete the remaining fields.',
discard: 'Clear these details and fill the form myself',
fieldVerified: 'From Fayda',
fieldConflict: 'Already used by another account',
conflictBody:
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
brandTitle: 'Verifying with Fayda',
brandSubtitle: 'One moment while we confirm your identity.',
verifying: 'Verifying your Fayda identity\u2026',
failedTitle: 'Verification incomplete',
backToSignup: 'Back to sign up',
cancelled: 'Fayda verification was cancelled. You can still sign up manually.',
rejected: 'Fayda could not verify your identity. Please try again.',
invalidCallback: 'This verification link is incomplete. Please start again.',
sessionLost: 'Your verification session has expired. Please start again.',
stateMismatch: 'This verification could not be trusted. Please start again.',
},
signup: {
usernameMinLength: 'Username must be at least 3 characters',
nameEnRequired: 'Name (English) is required',
@@ -802,6 +828,7 @@ export const en = {
},
licensing: {
certificateSuperseded: 'Certificate superseded',
vesselPicker: {
placeholder: 'Select a registered vessel',
},
@@ -826,6 +853,8 @@ export const en = {
renewDays_one: 'Renew — expires in {{count}} day',
renewDays_other: 'Renew — expires in {{count}} days',
renewFailed: 'Could not start the renewal',
reportDamaged: 'Report damaged / request replacement',
reissueFailed: 'Could not start the replacement request',
status: {
ACTIVE: 'Active',
EXPIRED: 'Expired',
@@ -860,6 +889,15 @@ export const en = {
},
},
pickup: {
title: 'Document Pickup',
scheduledFor: 'Visit the office on {{date}} ({{period}}) to collect your document.',
setByOffice: 'This appointment was scheduled by the licensing office.',
awaitingSchedule: 'The licensing office will assign a pickup date once your payment is confirmed.',
morning: 'Morning',
afternoon: 'Afternoon',
},
certificates: {
title: 'My Certificates',
loading: 'Loading Certificates…',
@@ -1015,6 +1053,10 @@ export const en = {
timeExpired: 'Time expired',
resumeExam: 'Resume exam',
takeExam: 'Take exam',
awaitingAttendance: 'Awaiting attendance',
awaitingAttendanceHint:
'An invigilator must confirm you are present before the exam opens.',
notSitting: 'Not sitting',
attendanceStatus: {
REGISTERED: 'Not called',
PRESENT: 'Present',

View File

@@ -8,6 +8,7 @@ import { LandingRoute } from "./components/LandingRoute";
import {
LoginPage,
SignupPage,
FaydaCallbackPage,
OTPVerificationPage,
ForgotPasswordPage,
SetPasswordPage,
@@ -68,6 +69,16 @@ export const router = createBrowserRouter([
{ path: "/login", element: <LoginPage /> },
{ path: "/signup", element: <SignupPage /> },
// Where Fayda returns the applicant. Public by necessity — they have no
// account yet. It redeems the code and hands control back to /signup.
//
// Two paths for one page: whichever is registered with Fayda has to match the
// API's FAYDA_REDIRECT_URI exactly, and the value being registered first is a
// bare /callback. The descriptive path is kept so the route still reads as
// part of signup once that can be changed.
{ path: "/signup/fayda/callback", element: <FaydaCallbackPage /> },
{ path: "/callback", element: <FaydaCallbackPage /> },
// Completes the forgot-password flow; the reset message links here. The
// IAM package generates `/reset-password` links, `/set-password` is the
// first-time-credential variant — one page serves both.

View File

@@ -1,31 +1,34 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import { nxViteTsPaths } from "@nx/vite/plugins/nx-tsconfig-paths.plugin";
export default defineConfig({
root: __dirname,
// Env lives at the workspace root, shared with the backoffice — without this
// Vite looks in apps/portal and VITE_BASE_API_URL silently falls back to its
// built-in default.
envDir: '../../',
cacheDir: '../../node_modules/.vite/apps/portal',
server: { port: 4200, host: 'localhost' },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3000', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 4200, host: 'localhost' },
envDir: "../../",
cacheDir: "../../node_modules/.vite/apps/portal",
// 3000, not the usual 4200: the Fayda redirect URI registered for local
// testing is http://localhost:3001/callback, and the provider matches it
// exactly. The API moves to 3001 to make room.
server: { port: 3000, host: "localhost" },
// server: {
// port: 4200,
// proxy: {
// '/api': {
// target: 'http://localhost:3001', // change from 'https://ema-api-dev.triaplc.com'
// changeOrigin: true,
// },
// },
// },
preview: { port: 3000, host: "localhost" },
plugins: [react(), nxViteTsPaths()],
resolve: {
dedupe: ['react', 'react-dom', 'react-router-dom', '@tanstack/react-query'],
dedupe: ["react", "react-dom", "react-router-dom", "@tanstack/react-query"],
},
build: {
outDir: '../../dist/apps/portal',
outDir: "../../dist/apps/portal",
emptyOutDir: true,
reportCompressedSize: true,
},