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

This commit is contained in:
Estifo77
2026-08-28 15:27:43 +03:00
35 changed files with 931 additions and 204 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,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

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

@@ -88,6 +88,8 @@ export const am: Translations = {
btcQueue: "የBTC ወረፋ",
cocQueue: "የCoC ወረፋ",
copQueue: "የCoP ወረፋ",
endorsementCocQueue: "የCoC እውቅና ወረፋ",
endorsementGocQueue: "የGOC እውቅና ወረፋ",
endorsementQueue: "የማስተያየት ወረፋ",
vesselRegistrations: "የመርከብ ምዝገባ",
// vesselRegistrationReport: 'የምዝገባ ሪፖርት',
@@ -261,7 +263,6 @@ export const am: Translations = {
both: "ሁለቱም",
offline: "ከመስመር ውጪ",
online: "በመስመር",
onlineChoiceOnlyHint: "የመስመር ላይ ፈተናዎች በራስ-ሰር ይመዘገባሉ፣ ይህም ለምርጫ ጥያቄዎች ብቻ ይሰራል።",
sum: "ድምር",
average: "አማካይ",
percentage: "መቶኛ",
@@ -273,6 +274,8 @@ export const am: Translations = {
cuttingPointPercentageHint: "የመቶኛ ግምገማ — ከ100 አይበልጥም።",
fillRequiredBasic: "በመሠረታዊ መረጃ ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ።",
fillRequiredSettings: "በቅንብሮች ውስጥ ያሉ አስፈላጊ መስኮችን ይሙሉ — ዓይነት፣ ቅጽ፣ የአስተዳደር ዘዴ፣ የግምገማ ዘዴ እና የማለፊያ ነጥብ።",
next: "ቀጣይ",
back: "ተመለስ",
directionBothLanguages: "መመሪያ በሁለቱም እንግሊዝኛ እና አማርኛ ጽሑፍ ያስፈልገዋል፣ ወይም ሁለቱንም ባዶ ይተዉ።",
status: "ሁኔታ",
statusPlaceholder: "የፈተና ሁኔታ",
@@ -389,6 +392,9 @@ export const am: Translations = {
notEnoughApproved: "ለዚህ ትምህርት በቂ የጸደቁ ጥያቄዎች የሉም።",
cannotReachCuttingPoint:
"ይህ ወረቀት የማለፊያ ነጥቡን ሊደርስ አይችልም (ከፍተኛ {{max}}፣ የማለፊያ ነጥብ {{cuttingPoint}})። ተጨማሪ ወይም ከፍ ያለ ነጥብ ያላቸው ጥያቄዎችን ጨምር፣ ወይም የማለፊያ ነጥቡን ቀንስ።",
paperLocked: "ወረቀቱ ተቆልፏል",
paperLockedHint:
"ለዚህ ፈተና {{count}} ተፈታኝ(ዎች) ተመዝግበዋል። ሁሉም ተፈታኞች አንድ ዓይነት ወረቀት መፈተን ስላለባቸው ጥያቄዎቹ ከዚህ በኋላ አይቀየሩም።",
},
country: {
@@ -659,6 +665,9 @@ export const am: Translations = {
seafarerPlaceholder: "መርከበኛ ይፈልጉ እና ይምረጡ",
scorePerQuestion: "በጥያቄ ውጤት",
question: "ጥያቄ",
candidateAnswer: "የተፈታኙ መልስ",
noAnswer: "የተመዘገበ መልስ የለም",
autoGraded: "በራስ-ሰር የተመዘነ",
maxPoints: "ከፍተኛ ውጤት",
score: "ውጤት",
remark: "ማስታወሻ",

View File

@@ -88,6 +88,8 @@ export const en = {
postWaiverQueue: 'Post-Waiver Queue',
cocQueue: 'CoC Queue',
copQueue: 'CoP Queue',
endorsementCocQueue: 'CoC Endorsement Queue',
endorsementGocQueue: 'GOC Endorsement Queue',
endorsementQueue: 'Endorsement Queue',
vesselRegistrations: 'Vessel Registration',
vesselTransfers: 'Vessel Ownership Transfer',
@@ -260,7 +262,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',
@@ -273,6 +274,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',
@@ -388,6 +391,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: {
@@ -659,6 +665,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',

View File

@@ -42,6 +42,12 @@ const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
*/
const SEAFARER_QUEUE = [P.VIEW_SEAFARER_REGISTRY, ...APPLICATION_QUEUE];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/**
* The backoffice information architecture.
*
@@ -149,7 +155,7 @@ export const NAV_SECTIONS: NavSection[] = [
to: "/biometric-enrollment",
label: "nav.biometricEnrollment",
icon: IconFingerprint,
permissions: [P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS],
permissions: BIOMETRIC_ENROLLMENT,
},
{
to: "/seafarer-registrations",

View File

@@ -53,6 +53,12 @@ import { BiometricEnrollmentPage } from '../features/biometric-enrollment/pages/
/** Any-of gate shared by every licence-type queue and its review workspace. */
const APPLICATION_QUEUE = [P.VIEW_APPLICATION_QUEUE, P.VIEW_APPLICATIONS];
const BIOMETRIC_ENROLLMENT = [
P.ENROLL_BIOMETRICS,
P.VIEW_BIOMETRICS,
P.VIEW_SEAFARER_REGISTRY,
];
/** Route gate: same keys as the route's nav item in nav-config.ts. */
const guard = (anyOf: string[], element: ReactNode) => (
<RequirePermission anyOf={anyOf}>{element}</RequirePermission>
@@ -101,7 +107,7 @@ const router = createBrowserRouter([
{ path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], <SeaServiceVerificationPage />) },
{ path: 'payment-config', element: guard([P.VIEW_PAYMENTS], <PaymentConfigPage />) },
{ path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], <SeafarerRegistryPage />) },
{ path: 'biometric-enrollment', element: guard([P.ENROLL_BIOMETRICS, P.VIEW_BIOMETRICS], <BiometricEnrollmentPage />) },
{ path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, <BiometricEnrollmentPage />) },
// Seafarer registration is not a licence: own queue, own review.
{ path: 'seafarer-registrations', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationQueuePage />) },
{ path: 'seafarer-registrations/:id', element: guard(APPLICATION_QUEUE, <SeafarerRegistrationReviewPage />) },

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

@@ -59,6 +59,7 @@ export const am: Translations = {
seaRecords: 'የባህር መዝገቦቼ',
seaService: 'የባህር አገልግሎት',
medical: 'የሕክምና የምስክር ወረቀት',
biometrics: 'ባዮሜትሪክ',
myApplication: 'ማመልከቻዬ',
certificates: 'የምስክር ወረቀቶች',
seamanBook: 'የመርከበኛ መጽሐፍ እና BTC',
@@ -603,6 +604,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: "ስም (እንግሊዝኛ) ያስፈልጋል",
@@ -989,6 +1012,10 @@ export const am: Translations = {
timeExpired: 'ጊዜው አልቋል',
resumeExam: 'ፈተና ይቀጥሉ',
takeExam: 'ፈተና ይውሰዱ',
awaitingAttendance: 'መገኘት በመጠባበቅ ላይ',
awaitingAttendanceHint:
'ፈተናው ከመከፈቱ በፊት ተቆጣጣሪ መገኘትዎን ማረጋገጥ አለበት።',
notSitting: 'አይፈተኑም',
attendanceStatus: {
REGISTERED: 'አልተጠራም',
PRESENT: 'ተገኝቷል',

View File

@@ -49,6 +49,7 @@ export const en = {
seaRecords: 'My Sea Records',
seaService: 'Sea Service',
medical: 'Medical Certificate',
biometrics: 'Biometrics',
myApplication: 'My Application',
vesselRegistration: 'Vessel Registration',
vesselRegistrationDashboard: 'Vessel Registration Dashboard',
@@ -603,6 +604,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',
@@ -991,6 +1014,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,
},