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

39
.env.example Normal file
View File

@@ -0,0 +1,39 @@
# emaui environment
#
# Vite reads this from the workspace root, not from the app directory:
# apps/portal/vite.config.mts and apps/backoffice/vite.config.mts both set
# `envDir: '../../'`. So copy this file to ./.env here, at the repo root.
#
# cp .env.example .env
#
# Only VITE_-prefixed values reach the browser, and everything that does is
# public — it ships inside the built bundle. Never put a secret here. The Fayda
# client id, private key and endpoints live in the API's environment only; the
# frontend never speaks to Fayda directly.
# Base URL of the emaapi backend, including the /api prefix.
# 3001, not 3000: the portal itself takes 3000 in development, because that is
# the port in the Fayda redirect URI registered for local testing. Set PORT=3001
# in emaapi's .env to match.
VITE_BASE_API_URL=http://localhost:3001/api
# Serve fixture data instead of calling the API. Any value other than "true"
# uses the real backend.
VITE_USE_MOCKS=false
# --- Docker Compose only -----------------------------------------------------
# Host ports published by docker-compose.yml. It also expects per-app env files
# at apps/portal/.env and apps/backoffice/.env, which can each be a copy of this
# file. Ignored when running the Vite dev servers, which serve the portal on
# 3000 and the backoffice on 4201.
# EMA_PORTAL_PORT=8021
# EMA_BACKOFFICE_PORT=8022
# --- Fayda note --------------------------------------------------------------
# There is nothing to configure here for Fayda. The portal serves the callback
# page at /callback and /signup/fayda/callback, and whichever path is registered
# with Fayda must match the API's FAYDA_REDIRECT_URI exactly.
#
# The value being registered first is http://localhost:3001/callback, so the
# portal's dev server now listens on 3000 and emaapi moves to 3001. Nothing
# extra to run — `nx serve portal` already binds the right port.

2
.gitignore vendored
View File

@@ -13,6 +13,8 @@ coverage/
# env
.env
.env.*
# ...but the checked-in template must survive that rule.
!.env.example
# logs
*.log

View File

@@ -6,20 +6,20 @@ A fully scaffolded Nx monorepo housing two Vite + React 19 SPAs (Backoffice and
## Tech Stack
| Tool | Version |
|------|---------|
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
| Tool | Version |
| --------------- | ------- |
| React | 19 |
| Nx | 22 |
| Vite | 7 |
| TypeScript | 5.9 |
| Redux Toolkit | 2.11 |
| Mantine | 8.3 |
| React Router | 7 |
| TanStack Query | 5 |
| React Hook Form | 7 |
| Zod | 4 |
| Tailwind CSS | 3.4 |
| Vitest | 4 |
---
@@ -37,16 +37,19 @@ emaui/
```
### libs/api
- `base-api/` — RTK Query `createApi` instance with `prepareHeaders` that injects the Bearer token from Redux state or storage.
- `session/``resolveTokenFromStorage()` reads the `auth-token` cookie first, falling back to `localStorage` for legacy pre-migration sessions. `resolveSessionContext()` merges Redux state token with storage fallback.
- `query-and-mutation/` — Generic `useApiQuery` / `useApiMutation` wrappers for one-off API calls without defining a dedicated endpoint file.
### libs/ui
- `ConfirmModal` — Reusable Mantine modal for destructive-action confirmation.
- `ApiErrorAlert` — Extracts a human-readable message from RTK Query error shapes or Error objects.
- `notify` — Thin wrapper around `@mantine/notifications` with `.success`, `.error`, `.info`, `.warning` helpers.
### libs/shared
- `ema-theme` — Mantine v8 `createTheme()` with `emaPrimary` (blue) and `emaSecondary` (warm) color tuples, Inter font, and custom shadow scale.
---
@@ -86,14 +89,14 @@ npm run dev:all
## Environment Variables
| Variable | Required | Default | Description |
|---|---|---|---|
| `VITE_BASE_API_URL` | Yes | `http://localhost:3000` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
| Variable | Required | Default | Description |
| ----------------------------- | ------------ | ----------------------- | ---------------------------------------------------------------------------------------------------------------------------------------- |
| `VITE_BASE_API_URL` | Yes | `http://localhost:3001` | Base URL for all API requests |
| `VITE_ENABLE_DEVELOPER_TOOLS` | No | `true` | Toggle Redux DevTools |
| `VITE_PAYMENT_API_URL` | Yes (portal) | — | Base URL for the payment service (portal `payment` feature). Can be a same-origin path if a backend proxy is later placed in front of it |
| `VITE_PAYMENT_SERVICE_TOKEN` | Yes (portal) | — | Sent as `x-service-token` on every payment request. Note: bundled `VITE_*` values are public in the built app, not secret |
| `PORTAL_PORT` | No | `4200` | Docker host port for portal |
| `BACKOFFICE_PORT` | No | `4201` | Docker host port for backoffice |
---
@@ -112,6 +115,7 @@ The `Dockerfile` uses multi-stage builds with named targets (`portal` / `backoff
## Adding a New Feature
1. Create the feature folder under the relevant app:
```
apps/backoffice/src/app/features/<feature-name>/
├── types/ # TypeScript interfaces

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

View File

@@ -8,5 +8,5 @@ export * from './lib/features/seafarer-registration';
export * from './lib/features/seafarer-document';
export * from './lib/features/biometric-enrollment';
export * from './lib/features/vessel';
export { baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { BASE_API_URL, baseQueryWithReauth, configureTokenRefresh } from './lib/base-api/base-query-with-reauth';
export { openAuthedDocument, downloadAuthedFile } from './lib/base-api/download';

View File

@@ -2,10 +2,15 @@ import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
import { resolveSessionContext } from "../session";
/**
* The one place the backend URL is resolved: VITE_BASE_API_URL from the env,
* falling back to the local dev API (3001 — the portal itself owns 3000 for
* the Fayda redirect). Import this; do not re-derive it.
*/
export const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
]?.trim() || "http://localhost:3001/api";
let _onTokenExpired: (() => Promise<string>) | null = null;
let _onAuthFailure: (() => void) | null = null;

View File

@@ -13,6 +13,7 @@ import type {
InitiatePaymentResult,
IssuedLicense,
Inspection,
IssuancePeriod,
LicenseApplication,
LicenseCategoryDefinition,
LicenseStatus,
@@ -26,6 +27,9 @@ import type {
ExportResult,
LicenseTemplate,
Paginated,
PickupAppointment,
PickupOffice,
PickupSlot,
QueueCounts,
QueueFilter,
Rank,
@@ -75,6 +79,8 @@ const TAGS = [
'SavedView',
'LicenseTemplate',
'DocumentRequirement',
'PickupOffice',
'PickupAppointment',
'Department',
'Rank',
] as const;
@@ -980,12 +986,12 @@ export const licensingApi = baseApi
scheduleIssuance: builder.mutation<
LicenseApplication,
{ id: string; scheduledDate: string }
{ id: string; scheduledDate: string; scheduledPeriod: IssuancePeriod }
>({
query: ({ id, scheduledDate }) => ({
query: ({ id, scheduledDate, scheduledPeriod }) => ({
url: `/license-application-review/${id}/schedule-issuance`,
method: 'POST',
body: { scheduledDate },
body: { scheduledDate, scheduledPeriod },
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
@@ -1000,6 +1006,104 @@ export const licensingApi = baseApi
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
}),
// ------------------------------------------------------------- pickup
getPickupOffices: builder.query<PickupOffice[], void>({
query: () => ({ url: '/pickup/offices' }),
providesTags: [listTag('PickupOffice')],
}),
getPickupSlots: builder.query<
PickupSlot[],
{ officeId: string; from: string; to: string }
>({
query: ({ officeId, from, to }) => ({
url: `/pickup/offices/${officeId}/slots`,
params: { from, to },
}),
}),
schedulePickup: builder.mutation<
PickupAppointment,
{ applicationId: string; officeId: string; date: string; slotStartTime: string }
>({
query: (body) => ({ url: '/pickup/appointments', method: 'POST', body }),
invalidatesTags: (_r, error, { applicationId }) =>
error
? []
: [
itemTag('LicenseApplication', applicationId),
listTag('ApplicationQueue'),
listTag('PickupAppointment'),
],
}),
reschedulePickup: builder.mutation<
PickupAppointment,
{ appointmentId: string; officeId: string; date: string; slotStartTime: string }
>({
query: ({ appointmentId, ...body }) => ({
url: `/pickup/appointments/${appointmentId}/reschedule`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { appointmentId }) =>
error ? [] : [itemTag('PickupAppointment', appointmentId), listTag('PickupAppointment')],
}),
getPickupAppointmentsForApplication: builder.query<PickupAppointment[], string>({
query: (applicationId) => ({
url: `/pickup/applications/${applicationId}/appointments`,
}),
providesTags: (_r, _e, applicationId) => [itemTag('PickupAppointment', applicationId)],
}),
getPickupWorklist: builder.query<
PickupAppointment[],
{ date: string; officeId?: string }
>({
query: ({ date, officeId }) => ({
url: '/pickup/appointments',
params: officeId ? { date, officeId } : { date },
}),
providesTags: [listTag('PickupAppointment')],
}),
checkInPickup: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/check-in`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupIssued: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/issued`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
markPickupNoShow: builder.mutation<PickupAppointment, string>({
query: (id) => ({ url: `/pickup/appointments/${id}/no-show`, method: 'PATCH' }),
invalidatesTags: (_r, error, id) =>
error ? [] : [itemTag('PickupAppointment', id), listTag('PickupAppointment')],
}),
createPickupOffice: builder.mutation<PickupOffice, Partial<PickupOffice>>({
query: (body) => ({ url: '/pickup/offices', method: 'POST', body }),
invalidatesTags: [listTag('PickupOffice')],
}),
updatePickupOffice: builder.mutation<
PickupOffice,
{ id: string } & Partial<PickupOffice>
>({
query: ({ id, ...body }) => ({
url: `/pickup/offices/${id}`,
method: 'PATCH',
body,
}),
invalidatesTags: (_r, error, { id }) =>
error ? [] : [itemTag('PickupOffice', id), listTag('PickupOffice')],
}),
// --------------------------------------------------------- inspection
scheduleInspection: builder.mutation<
Inspection,
@@ -1175,6 +1279,17 @@ export const {
useConfirmPaymentMutation,
useScheduleIssuanceMutation,
useIssueCertificateMutation,
useGetPickupOfficesQuery,
useGetPickupSlotsQuery,
useSchedulePickupMutation,
useReschedulePickupMutation,
useGetPickupAppointmentsForApplicationQuery,
useGetPickupWorklistQuery,
useCheckInPickupMutation,
useMarkPickupIssuedMutation,
useMarkPickupNoShowMutation,
useCreatePickupOfficeMutation,
useUpdatePickupOfficeMutation,
useScheduleInspectionMutation,
useRescheduleInspectionMutation,
useGetInspectionsQuery,

View File

@@ -1,6 +1,8 @@
import { isValidPhoneNumber } from 'libphonenumber-js';
import { resolveTokenFromStorage } from '../../session';
import { BASE_API_URL } from '../../base-api/base-query-with-reauth';
import type {
ApplicationKind,
Bilingual,
FamilyKind,
FieldCondition,
@@ -11,9 +13,10 @@ import type {
ValidationIssue,
} from './licensing.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
/** A section with no `applicationKinds` applies to every kind, as before that field existed. */
function sectionAppliesToKind(section: FormSectionConfig, kind: ApplicationKind): boolean {
return !section.applicationKinds?.length || section.applicationKinds.includes(kind);
}
/**
* Uploads a document straight to the API.
@@ -452,12 +455,28 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/**
* Whether this application has any document requirements to upload.
* False for a Damaged/Reissue application, which asks nothing beyond the
* Damage Information step — showing an empty Documents page would be a
* page to click past for nothing.
*/
hasDocuments?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
/**
* The application's kind — NEW unless the caller is renewing or
* reissuing. A section scoped to a different kind via
* `applicationKinds` is left out entirely, the same as a `showWhen`
* that never holds.
*/
applicationKind?: ApplicationKind;
},
): WizardStep[] {
const kind = options?.applicationKind ?? 'NEW';
const visible = [...sections]
.filter((section) => sectionAppliesToKind(section, kind))
.filter((section) => conditionHolds(section.showWhen, formData))
.sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
@@ -512,7 +531,9 @@ export function buildWizardSteps(
...(options?.hasStaff === false
? []
: [{ key: 'staff', label: 'Staff', kind: 'staff', sections: [] } as WizardStep]),
{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] },
...(options?.hasDocuments === false
? []
: [{ key: 'documents', label: 'Documents', kind: 'documents', sections: [] } as WizardStep]),
{ key: 'review', label: 'Review', kind: 'review', sections: reviewSections },
];
}

View File

@@ -63,7 +63,10 @@ export type LicenseStatus =
| "EXAM_PASSED"
| "EXAM_FAILED";
export type ApplicationKind = "NEW" | "RENEWAL";
export type ApplicationKind = "NEW" | "RENEWAL" | "REISSUE";
/** Half-day window a team leader books an applicant's document pickup into. */
export type IssuancePeriod = "MORNING" | "AFTERNOON";
export type FormFieldType =
| "TEXT"
@@ -120,6 +123,12 @@ export interface FormSectionConfig {
group?: string;
/** Position of the group in the stepper; lowest value in a group wins. */
groupOrder?: number;
/**
* Restricts this section to specific application kinds — e.g. the
* Damaged/Reissue "Damage Information" step. Undefined or empty means
* every kind.
*/
applicationKinds?: ApplicationKind[];
}
/** Grouping the portal organises the licence catalogue by. */
@@ -349,6 +358,7 @@ export interface LicenseApplication {
issuedLicenseId: string | null;
/** Set once an officer schedules pickup for a document requiring in-person handover. */
scheduledIssuanceDate: string | null;
scheduledIssuancePeriod: IssuancePeriod | null;
scheduledBy: string | null;
createdAt: string;
}
@@ -440,6 +450,13 @@ export interface ApplicationApplicant {
export interface ApplicationDetail {
application: LicenseApplication;
/**
* Current status of the license this application issued, independent of
* the application's own (permanently historical) status — a later
* reissue/renewal can supersede the license without changing what this
* application itself accomplished. Null when nothing has been issued yet.
*/
issuedLicenseStatus: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED" | null;
relatedApplications?: LicenseApplication[];
/** Null when the applicant has no profile row (never expected in practice). */
applicant: ApplicationApplicant | null;
@@ -466,6 +483,50 @@ export interface Inspection {
findings: string | null;
}
export type PickupAppointmentStatus =
| "SCHEDULED"
| "CHECKED_IN"
| "ISSUED"
| "NO_SHOW"
| "RESCHEDULED"
| "CANCELLED";
export interface PickupOffice {
id: string;
name: string;
address: string | null;
/** 0=Sunday .. 6=Saturday. */
workingDays: number[];
startTime: string;
endTime: string;
slotDurationMinutes: number;
maxApplicantsPerSlot: number;
rescheduleMinNoticeHours: number;
isActive: boolean;
}
export interface PickupSlot {
date: string;
slotStartTime: string;
capacity: number;
booked: number;
available: number;
}
export interface PickupAppointment {
id: string;
applicationId: string;
appointmentNumber: string;
officeId: string;
date: string;
slotStartTime: string;
status: PickupAppointmentStatus;
rescheduledFromId: string | null;
rescheduleCount: number;
checkedInAt: string | null;
checkedInById: string | null;
}
export interface AppNotification {
id: string;
subject: Bilingual;
@@ -482,6 +543,7 @@ export interface QueueFilter {
licenseTypeId?: string;
search?: string;
status?: LicenseStatus[];
kind?: ApplicationKind;
/** Officer uuid, or the literal 'unassigned'. */
assignee?: string;
submittedFrom?: string;
@@ -747,6 +809,8 @@ export interface IssuedLicense {
* configuration.
*/
renewable?: boolean;
/** Whether a Damaged/Reissue replacement may be requested for this licence. */
reissuable?: boolean;
verificationCode: string;
certificateFileKey: string | null;
}

View File

@@ -4,6 +4,7 @@ import type {
SeafarerDocument,
SeafarerDocumentDetail,
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentRow,
SeafarerDocumentStatus,
} from './seafarer-document.types';
@@ -14,6 +15,7 @@ const item = (id: string) => ({ type: TAG, id }) as const;
export interface SeafarerDocumentListFilter {
kind?: SeafarerDocumentKind;
requestKind?: SeafarerDocumentRequestKind;
status?: SeafarerDocumentStatus;
search?: string;
take?: number;
@@ -63,6 +65,16 @@ export const seafarerDocumentApi = baseApi
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
}),
renewSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/renew`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
replaceSeafarerDocument: builder.mutation<SeafarerDocument, string>({
query: (id) => ({ url: `/seafarer-documents/${id}/replace`, method: 'POST' }),
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
}),
// --------------------------------------------------------------- review
listSeafarerDocuments: builder.query<
{ total: number; items: SeafarerDocumentRow[] },
@@ -121,6 +133,8 @@ export const {
useInitiateDocumentPaymentMutation,
useGetDocumentPaymentQuery,
useBypassDocumentPaymentMutation,
useRenewSeafarerDocumentMutation,
useReplaceSeafarerDocumentMutation,
useListSeafarerDocumentsQuery,
useGetSeafarerDocumentReviewQuery,
useLazyGetSeafarerDocumentReviewDownloadQuery,

View File

@@ -1,10 +1,26 @@
import type { SeafarerDocumentKind, SeafarerDocumentStatus } from './seafarer-document.types';
import type {
SeafarerDocumentKind,
SeafarerDocumentRequestKind,
SeafarerDocumentStatus,
} from './seafarer-document.types';
export const SEAFARER_DOCUMENT_KIND_LABELS: Record<SeafarerDocumentKind, string> = {
SEAMAN_BOOK: 'Seaman Book',
BTC_BASIC_TRAINING: 'Basic Training Certificate',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_LABELS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'New',
RENEWAL: 'Renewal',
REPLACEMENT: 'Replacement',
};
export const SEAFARER_DOCUMENT_REQUEST_KIND_COLORS: Record<SeafarerDocumentRequestKind, string> = {
NEW: 'gray',
RENEWAL: 'blue',
REPLACEMENT: 'orange',
};
export const SEAFARER_DOCUMENT_STATUS_LABELS: Record<SeafarerDocumentStatus, string> = {
AWAITING_REGISTRATION: 'Awaiting Registration',
PAYMENT_PENDING: 'Payment Pending',

View File

@@ -12,14 +12,19 @@ export type SeafarerDocumentStatus =
| 'REJECTED'
| 'CANCELLED';
/** A Seaman Book or BTC request — opened by a seafarer registration. */
/** NEW comes from a seafarer registration; RENEWAL/REPLACEMENT are applicant-initiated. */
export type SeafarerDocumentRequestKind = 'NEW' | 'RENEWAL' | 'REPLACEMENT';
/** A Seaman Book or BTC request — opened by a seafarer registration, or by the applicant as a renewal/replacement. */
export interface SeafarerDocument {
id: string;
kind: SeafarerDocumentKind;
requestKind: SeafarerDocumentRequestKind;
requestNumber: string;
applicantUserId: string;
profileId: string | null;
seafarerRegistrationId: string | null;
previousDocumentId: string | null;
status: SeafarerDocumentStatus;
feeAmount: number | null;
feeCurrency: string;

View File

@@ -1,17 +1,17 @@
import Cookies from 'js-cookie';
import Cookies from "js-cookie";
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
currentPositionId: 'x-current-position-id',
currentProjectId: 'x-current-project-id',
tenantId: "x-tenant-id",
organizationUnitId: "x-organization-unit-id",
currentPositionId: "x-current-position-id",
currentProjectId: "x-current-project-id",
} as const;
/**
* Which app this bundle is, so it reads its own session and no one else's.
*
* Set by each app's store via `configureSessionScope`. Cookies ignore the
* port, so `localhost:4200` and `localhost:4201` share one jar: without a
* port, so `localhost:3001` and `localhost:4201` share one jar: without a
* scope the backoffice would happily authenticate as whoever last signed into
* the portal, and render a staff console with an applicant's permissions.
*/
@@ -22,7 +22,7 @@ export function configureSessionScope(prefix: string): void {
}
/** Legacy, pre-prefix sessions. Read only when the scoped key is empty. */
const LEGACY_TOKEN_KEY = 'auth-token';
const LEGACY_TOKEN_KEY = "auth-token";
export function resolveTokenFromStorage(): string | undefined {
// Only this app's key, then the legacy unprefixed one. Never another app's:

View File

@@ -6,6 +6,7 @@ export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { useIdleTimer } from "./lib/hooks/useIdleTimer";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { FaydaCallbackPage } from "./lib/pages/FaydaCallbackPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";

View File

@@ -6,9 +6,7 @@ import { hydrateAuth, logout, setToken, setUser } from '../store/auth.slice';
import { refreshAccessToken } from '../utils/refresh-token';
import type { AuthUser } from '../types/auth.types';
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3000/api';
import { BASE_API_URL } from '@ema-platform/api';
/**
* Restores the signed-in session before the router renders.

View File

@@ -0,0 +1,121 @@
import { useEffect, useRef, useState } from 'react';
import { Alert, Button, Group, Loader, Stack, Text, Title } from '@mantine/core';
import { IconAlertTriangle, IconArrowLeft } from '@tabler/icons-react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { useApiMutation } from '@ema-platform/api';
import { useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
/**
* Where Fayda returns the applicant.
*
* It creates no account and holds no credentials — it hands the authorization
* code to the API, stashes the normalised result, and sends the applicant back
* to the signup form they started on.
*/
export function FaydaCallbackPage() {
const navigate = useNavigate();
const { t } = useTranslation();
const [params] = useSearchParams();
const { handleError } = useErrorHandler();
const [error, setError] = useState<string | null>(null);
const [callbackTrigger] = useApiMutation<FaydaResult>();
// React 18 mounts effects twice in development, and the authorization code is
// single-use — the second redemption would fail and show a spurious error.
const redeemed = useRef(false);
useEffect(() => {
if (redeemed.current) return;
redeemed.current = true;
const code = params.get('code');
const state = params.get('state');
const providerError = params.get('error');
const request = faydaSession.takeRequest();
if (providerError) {
setError(
providerError === 'access_denied'
? t('fayda.cancelled', 'Fayda verification was cancelled. You can still sign up manually.')
: t('fayda.rejected', 'Fayda could not verify your identity. Please try again.'),
);
return;
}
if (!code || !state) {
setError(t('fayda.invalidCallback', 'This verification link is incomplete. Please start again.'));
return;
}
if (!request) {
setError(
t('fayda.sessionLost', 'Your verification session has expired. Please start again.'),
);
return;
}
if (request.state !== state) {
setError(t('fayda.stateMismatch', 'This verification could not be trusted. Please start again.'));
return;
}
callbackTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
// `verify` returns the identity without creating an account — the
// existing signup endpoint still does that.
body: { action: 'verify', code, state, transactionToken: request.transactionToken },
})
.unwrap()
.then((result) => {
faydaSession.saveResult(result);
// replace: the callback URL carries a spent code, so it must not come
// back on Back.
navigate('/signup', { replace: true });
})
.catch((err: unknown) => setError(handleError(err)));
// Runs once on mount; the guard above makes that explicit.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<AuthShell
brandTitle={t('fayda.brandTitle', 'Verifying with Fayda')}
brandSubtitle={t('fayda.brandSubtitle', 'One moment while we confirm your identity.')}
>
<Stack gap="lg">
{error ? (
<>
<Title order={2} fz={26}>
{t('fayda.failedTitle', 'Verification incomplete')}
</Title>
<Alert
variant="light"
color="orange"
icon={<IconAlertTriangle size={18} />}
>
{error}
</Alert>
<Group>
<Button
variant="light"
leftSection={<IconArrowLeft size={18} />}
onClick={() => navigate('/signup', { replace: true })}
>
{t('fayda.backToSignup', 'Back to sign up')}
</Button>
</Group>
</>
) : (
<Group gap="sm">
<Loader size="sm" />
<Text c="dimmed">{t('fayda.verifying', 'Verifying your Fayda identity…')}</Text>
</Group>
)}
</Stack>
</AuthShell>
);
}

View File

@@ -1,10 +1,11 @@
import { useState } from 'react';
import { useEffect, useState } from 'react';
import {
Alert,
Anchor,
Badge,
Button,
Checkbox,
Group,
Divider,
PasswordInput,
SimpleGrid,
Stack,
@@ -14,11 +15,14 @@ import {
UnstyledButton,
} from '@mantine/core';
import {
IconAlertTriangle,
IconArrowLeft,
IconArrowRight,
IconAt,
IconId,
IconLock,
IconMail,
IconRosetteDiscountCheck,
IconUser,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
@@ -33,6 +37,7 @@ import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
import { faydaSession, type FaydaResult } from '../utils/fayda-session';
interface SignupPayload {
email: string;
@@ -62,6 +67,56 @@ export function SignupPage() {
}>();
const [meTrigger] = useApiMutation<AuthUser>();
// Fayda is optional: the form below works exactly as before without it.
const [fayda, setFayda] = useState<FaydaResult | null>(() => faydaSession.peekResult());
const [faydaStarting, setFaydaStarting] = useState(false);
const [startTrigger] = useApiMutation<{
authorizationUrl: string;
state: string;
transactionToken: string;
expiresIn: number;
}>();
const [linkTrigger] = useApiMutation<{ phoneNumberVerified?: boolean }>();
const verified = (field: string) => fayda?.verifiedFields.includes(field) ?? false;
const conflicted = (field: string) => fayda?.conflicts.includes(field) ?? false;
/**
* Per-field provenance, so it is obvious which values came from Fayda and
* which are still the applicant's to supply. Verified fields stay editable —
* a conflicting email has to be changeable for the form to be completable at
* all.
*/
const faydaMark = (field: string): { description?: React.ReactNode } => {
if (conflicted(field)) {
return {
// component="span" on these badges: the description slot renders
// inside a <p>, where Badge's default <div> is invalid HTML.
description: (
<Badge component="span" size="xs" variant="light" color="orange">
{t('fayda.fieldConflict', 'Already used by another account')}
</Badge>
),
};
}
if (verified(field)) {
return {
description: (
<Badge
component="span"
size="xs"
variant="light"
color="teal"
leftSection={<IconRosetteDiscountCheck size={11} />}
>
{t('fayda.fieldVerified', 'From Fayda')}
</Badge>
),
};
}
return {};
};
const handleBack = () => {
if (window.history.length > 1) {
navigate(-1);
@@ -118,6 +173,42 @@ export function SignupPage() {
defaultValues: { userType: 'individual' },
});
// Fills what Fayda vouched for and leaves the rest — username and password
// are always the applicant's to choose, and Fayda supplies neither.
useEffect(() => {
if (!fayda) return;
const { email, phoneNumber: phone, nameEn, nameAm } = fayda.identity;
if (email) setValue('email', email);
if (phone) setValue('phoneNumber', phone);
if (nameEn) setValue('nameEn', nameEn);
if (nameAm) setValue('nameAm', nameAm);
}, [fayda, setValue]);
const startFayda = async () => {
setServerError(null);
setFaydaStarting(true);
try {
// Same endpoint the registration itself uses; `start` only opens the
// attempt and hands back where to send the user.
const { authorizationUrl, transactionToken, state } = await startTrigger({
url: '/auth/register-with-fayda',
method: 'POST',
body: { action: 'start' },
}).unwrap();
faydaSession.saveRequest({ transactionToken, state });
window.location.assign(authorizationUrl);
} catch (err: unknown) {
setFaydaStarting(false);
setServerError(handleError(err));
}
};
const clearFayda = () => {
faydaSession.clearResult();
setFayda(null);
};
const onSubmit = async (values: FormValues) => {
try {
const payload: SignupPayload = {
@@ -147,7 +238,28 @@ export function SignupPage() {
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
dispatch(setUser(me));
if (data.isPhoneNumberVerified) {
// Records the Fayda-verified identity on the new account: marks the
// phone verified when it is the one Fayda vouched for, and fills the
// still-empty profile fields. Best-effort — the account already works,
// and the token can be presented again on a retry.
let faydaPhoneVerified = false;
if (fayda?.verificationToken) {
try {
const applied = await linkTrigger({
url: '/profiles/me/fayda',
method: 'POST',
body: { verificationToken: fayda.verificationToken },
}).unwrap();
faydaPhoneVerified = Boolean(applied?.phoneNumberVerified);
} catch {
/* deliberately ignored — signup already succeeded */
}
}
faydaSession.clearResult();
// Fayda already verified this exact number via its own OTP; asking for
// a second OTP on the same number is theatre.
if (data.isPhoneNumberVerified || faydaPhoneVerified) {
navigate(loginRedirectPath);
} else {
navigate('/otp-verify', {
@@ -212,6 +324,53 @@ export function SignupPage() {
</Alert>
)}
{fayda ? (
<Alert
variant="light"
color="teal"
icon={<IconRosetteDiscountCheck size={18} />}
title={t('fayda.verifiedTitle', 'Verified with Fayda')}
>
<Stack gap="xs">
<Text size="sm">
{t(
'fayda.verifiedBody',
'We filled in the details Fayda confirmed. Please complete the remaining fields.',
)}
</Text>
<Anchor size="sm" component="button" type="button" onClick={clearFayda}>
{t('fayda.discard', 'Clear these details and fill the form myself')}
</Anchor>
</Stack>
</Alert>
) : (
<>
<Button
variant="default"
size="md"
fullWidth
loading={faydaStarting}
leftSection={<IconId size={18} />}
onClick={startFayda}
>
{t('fayda.continueWith', 'Continue with Fayda')}
</Button>
<Divider
label={t('fayda.orFillManually', 'or fill in your details')}
labelPosition="center"
/>
</>
)}
{fayda && fayda.conflicts.length > 0 && (
<Alert variant="light" color="orange" icon={<IconAlertTriangle size={18} />}>
{t(
'fayda.conflictBody',
'Some verified details already belong to another account. Change the highlighted fields, or sign in instead.',
)}
</Alert>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
@@ -220,6 +379,7 @@ export function SignupPage() {
placeholder={t('signup.nameEnPlaceholder', 'Abebe Bekele')}
leftSection={<IconUser size={18} />}
error={errors.nameEn?.message}
{...faydaMark('nameEn')}
{...register('nameEn')}
/>
<TextInput
@@ -227,6 +387,7 @@ export function SignupPage() {
placeholder={t('signup.nameAmPlaceholder', 'ስም')}
leftSection={<IconUser size={18} />}
error={errors.nameAm?.message}
{...faydaMark('nameAm')}
{...register('nameAm')}
/>
</SimpleGrid>
@@ -237,6 +398,7 @@ export function SignupPage() {
placeholder={t('signup.emailPlaceholder', 'you@example.com')}
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...faydaMark('email')}
{...register('email')}
/>
<TextInput
@@ -255,6 +417,7 @@ export function SignupPage() {
onChange={(val) => setValue('phoneNumber', val, { shouldValidate: !!errors.phoneNumber })}
onBlur={() => trigger('phoneNumber')}
error={errors.phoneNumber?.message}
{...faydaMark('phoneNumber')}
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">

View File

@@ -0,0 +1,87 @@
/**
* The Fayda round trip leaves the app entirely, so the little state that has to
* survive it lives in sessionStorage: same tab, same origin, gone when the tab
* closes.
*
* Nothing secret is kept here. The `transactionToken` is signed by the API and
* useless without it — the PKCE verifier, the nonce and the client key never
* leave the backend.
*/
const REQUEST_KEY = 'fayda:request';
const RESULT_KEY = 'fayda:result';
export interface FaydaRequest {
transactionToken: string;
state: string;
}
export interface FaydaPrefill {
email?: string;
phoneNumber?: string;
nameEn?: string;
nameAm?: string;
/** Shown for context only — the signup form has no field for these. */
gender?: string;
address?: string;
birthdate?: string;
nationality?: string;
faydaNumber?: string;
}
/** Shape of `POST /auth/register-with-fayda` with `action: "verify"`. */
export interface FaydaResult {
identity: FaydaPrefill;
faydaVerified: boolean;
/** Signup fields Fayda vouched for. */
verifiedFields: string[];
/** Prefilled fields already taken by another account. */
conflicts: string[];
/**
* Encrypted proof of the verification, presented to POST /profiles/me/fayda
* after signup so the account and profile record what Fayda vouched for.
*/
verificationToken: string;
}
// Private browsing and locked-down browsers can throw on access, and a failure
// here should degrade to "no Fayda prefill", never break the signup page.
function read<T>(key: string): T | null {
try {
const raw = sessionStorage.getItem(key);
return raw ? (JSON.parse(raw) as T) : null;
} catch {
return null;
}
}
function write(key: string, value: unknown): void {
try {
sessionStorage.setItem(key, JSON.stringify(value));
} catch {
/* nothing to do — the flow reports a generic failure instead */
}
}
function clear(key: string): void {
try {
sessionStorage.removeItem(key);
} catch {
/* ignore */
}
}
export const faydaSession = {
saveRequest: (request: FaydaRequest) => write(REQUEST_KEY, request),
takeRequest: (): FaydaRequest | null => {
const request = read<FaydaRequest>(REQUEST_KEY);
// Single use: a stale token would otherwise be replayed against a fresh
// callback and fail with a confusing "session expired".
clear(REQUEST_KEY);
return request;
},
saveResult: (result: FaydaResult) => write(RESULT_KEY, result),
peekResult: (): FaydaResult | null => read<FaydaResult>(RESULT_KEY),
clearResult: () => clear(RESULT_KEY),
};

View File

@@ -1,9 +1,6 @@
import { authStorage } from "./auth-storage";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3000/api";
import { BASE_API_URL } from "@ema-platform/api";
interface RefreshResponse {
token: string;