diff --git a/.env.example b/.env.example new file mode 100644 index 000000000..eed81c69a --- /dev/null +++ b/.env.example @@ -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. diff --git a/.gitignore b/.gitignore index 4b15bf545..8b70b3e9d 100644 --- a/.gitignore +++ b/.gitignore @@ -13,6 +13,8 @@ coverage/ # env .env .env.* +# ...but the checked-in template must survive that rule. +!.env.example # logs *.log diff --git a/README.md b/README.md index 361b9afd8..9b3ae76c1 100644 --- a/README.md +++ b/README.md @@ -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// ├── types/ # TypeScript interfaces diff --git a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts index e9f4d2892..115ea32e6 100644 --- a/apps/backoffice/src/app/features/certificate-designer/config/designer.ts +++ b/apps/backoffice/src/app/features/certificate-designer/config/designer.ts @@ -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 }).env?.['VITE_BASE_API_URL'] ?? - 'http://localhost:3000/api'; +export const API_BASE_URL = BASE_API_URL; export const STATUS_COLOR: Record = { DRAFT: 'gray', diff --git a/apps/backoffice/src/app/features/exam/api/exam-api.ts b/apps/backoffice/src/app/features/exam/api/exam-api.ts index d313a38ac..4dbe8f713 100644 --- a/apps/backoffice/src/app/features/exam/api/exam-api.ts +++ b/apps/backoffice/src/app/features/exam/api/exam-api.ts @@ -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; diff --git a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx index a17673197..ff8759458 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamDetailPage.tsx @@ -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 })} - + + {paperLocked && ( + + {t("exam.paperLocked")} + + )} + + {/* Wrapped: a disabled Mantine Button fires no pointer events, + so the tooltip needs an enabled element to hang off. */} + + + + + {(exam.questions ?? []).length === 0 ? ( - }> - {t("exam.noQuestionsAssigned")} + } + > + {paperLocked + ? t("exam.paperLockedHint", { count: registrations?.length ?? 0 }) + : t("exam.noQuestionsAssigned")} ) : ( diff --git a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx index 874526c70..c4578d308 100644 --- a/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx +++ b/apps/backoffice/src/app/features/exam/pages/ExamPage/index.tsx @@ -79,21 +79,44 @@ function ExamForm({ const [status, setStatus] = useState(editing?.status ?? null); const [activeTab, setActiveTab] = useState("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 - } /> setFacet({ kind: (v as ApplicationKind) ?? undefined })} + clearable + w={180} + /> ( + "MORNING", + ); const [resultOpen, setResultOpen] = useState(false); const [scheduleExamOpen, setScheduleExamOpen] = useState(false); const [examOutcomeOpen, setExamOutcomeOpen] = useState(false); @@ -884,6 +887,11 @@ export function LicenseReviewPage() { {t(`queue.statusValues.${status}`, STATUS_LABELS[status])} + {data.issuedLicenseStatus === "SUPERSEDED" && ( + + {t("review.certificateSuperseded", "Certificate superseded")} + + )} {app.adjustmentRound > 0 && ( {t("review.round", { @@ -1585,6 +1593,14 @@ export function LicenseReviewPage() { value={issuanceDate} onChange={setIssuanceDate} /> + setIssuancePeriod(value as "MORNING" | "AFTERNOON")} + data={[ + { value: "MORNING", label: t("review.morning", "Morning") }, + { value: "AFTERNOON", label: t("review.afternoon", "Afternoon") }, + ]} + /> {/* 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); }, diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx new file mode 100644 index 000000000..f14cb4c1b --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/actions.tsx @@ -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 { + return { + header: '', + size: 260, + align: 'right', + cell: ({ row }) => { + const appointment = row.original; + const loading = loadingId === appointment.id; + return ( + + {appointment.status === 'SCHEDULED' && ( + + + + )} + {(appointment.status === 'SCHEDULED' || appointment.status === 'CHECKED_IN') && ( + <> + + + + + + + + )} + + ); + }, + }; +} diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx new file mode 100644 index 000000000..821081e92 --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/columns.tsx @@ -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 = { + SCHEDULED: 'cyan', + CHECKED_IN: 'yellow', + ISSUED: 'green', + NO_SHOW: 'red', + RESCHEDULED: 'gray', + CANCELLED: 'gray', +}; + +export function pickupDeskColumns( + t: TFunction, + officesById: Map, +): AdvancedColumn[] { + return [ + { + header: t('pickupDesk.columns.time', 'Time'), + cell: ({ row }) => ( + + {row.original.slotStartTime} + + ), + }, + { + header: t('pickupDesk.columns.appointment', 'Appointment'), + cell: ({ row }) => ( + + {row.original.appointmentNumber} + + ), + }, + { + header: t('pickupDesk.columns.office', 'Office'), + cell: ({ row }) => ( + {officesById.get(row.original.officeId)?.name ?? '—'} + ), + }, + { + header: t('pickupDesk.columns.status', 'Status'), + cell: ({ row }) => ( + + {row.original.status.replace('_', ' ')} + + ), + }, + ]; +} diff --git a/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx new file mode 100644 index 000000000..b14235e11 --- /dev/null +++ b/apps/backoffice/src/app/features/pickup/pages/PickupDeskPage/index.tsx @@ -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(null); + const [loadingId, setLoadingId] = useState(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) { + 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 ( + + + + + } + /> + + + + { + setRequestKind(v as SeafarerDocumentRequestKind | null); + setPage(0); + }} + clearable + w={160} + /> } itemCount={data?.total ?? 0} diff --git a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx index d1825a99b..ba8fcc916 100644 --- a/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx +++ b/apps/backoffice/src/app/features/seafarer-document-review/pages/SeafarerDocumentReviewPage.tsx @@ -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() { {SEAFARER_DOCUMENT_STATUS_LABELS[document.status]} + {document.requestKind !== 'NEW' && ( + + {SEAFARER_DOCUMENT_REQUEST_KIND_LABELS[document.requestKind]} + + )} {document.documentNumber && ( }> {document.documentNumber} diff --git a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx index a3e3d8f90..7cd82395f 100644 --- a/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx +++ b/apps/backoffice/src/app/features/user-management/UserManagementPage.tsx @@ -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( - , + , ); 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)'; - }} - > - + e.currentTarget.style.background = "#ffffff"; + e.currentTarget.style.boxShadow = "0 1px 3px rgba(0,0,0,0.08)"; + }}> + Return to EMA -
+
); } diff --git a/apps/backoffice/src/app/i18n/locales/am.ts b/apps/backoffice/src/app/i18n/locales/am.ts index 8eea67b42..83f310688 100644 --- a/apps/backoffice/src/app/i18n/locales/am.ts +++ b/apps/backoffice/src/app/i18n/locales/am.ts @@ -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: "የበላይ ኃላፊ", diff --git a/apps/backoffice/src/app/i18n/locales/en.ts b/apps/backoffice/src/app/i18n/locales/en.ts index 13eaddd45..39adf6b87 100644 --- a/apps/backoffice/src/app/i18n/locales/en.ts +++ b/apps/backoffice/src/app/i18n/locales/en.ts @@ -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', diff --git a/apps/backoffice/src/app/layouts/nav-config.ts b/apps/backoffice/src/app/layouts/nav-config.ts index 9ed42fd62..7df1c5bcf 100644 --- a/apps/backoffice/src/app/layouts/nav-config.ts +++ b/apps/backoffice/src/app/layouts/nav-config.ts @@ -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], + }, ], }, { diff --git a/apps/backoffice/src/app/router/index.tsx b/apps/backoffice/src/app/router/index.tsx index 12a801650..f7f1ac616 100644 --- a/apps/backoffice/src/app/router/index.tsx +++ b/apps/backoffice/src/app/router/index.tsx @@ -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], ) }, { path: 'sea-service-verification', element: guard([P.VERIFY_SEAFARER_RECORDS], ) }, { path: 'payment-config', element: guard([P.VIEW_PAYMENTS], ) }, + { path: 'pickup-desk', element: guard([P.MANAGE_PICKUP_DESK], ) }, + { path: 'pickup-offices', element: guard([P.CONFIGURE_PICKUP_OFFICES], ) }, { path: 'seafarer-registry', element: guard([P.VIEW_SEAFARER_REGISTRY], ) }, { path: 'biometric-enrollment', element: guard(BIOMETRIC_ENROLLMENT, ) }, // Seafarer registration is not a licence: own queue, own review. diff --git a/apps/backoffice/vite.config.mts b/apps/backoffice/vite.config.mts index 355a26fdf..84f66adb4 100644 --- a/apps/backoffice/vite.config.mts +++ b/apps/backoffice/vite.config.mts @@ -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, // }, // }, diff --git a/apps/e2e/README.md b/apps/e2e/README.md index cfec73a58..86c75a6da 100644 --- a/apps/e2e/README.md +++ b/apps/e2e/README.md @@ -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`, diff --git a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx index c15fa983b..f7cf52743 100644 --- a/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx +++ b/apps/portal/src/app/features/certificates/pages/CertificatesPage.tsx @@ -120,9 +120,7 @@ function formatDate(value: string | null | undefined): string { }); } -const API_BASE = - (import.meta as { env?: Record }).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 { const token = authStorage.getToken(); diff --git a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts index e671dba76..baa445600 100644 --- a/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts +++ b/apps/portal/src/app/features/exam-attempt/hooks/useExamAttempt.ts @@ -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]); diff --git a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx index 06cf8a4f6..652e72fc3 100644 --- a/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx +++ b/apps/portal/src/app/features/exams/pages/ExamsPage/columns.tsx @@ -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 = { 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( ); } - 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 ( + + + {t('exams.columns.awaitingAttendance')} + + + ); + } + if (!MAY_SIT.includes(row.original.attendanceStatus)) { + return ( + + {t('exams.columns.notSitting')} + + ); + } + if (exam?.status !== 'ACTIVE') return null; return ( )} + + {/* Damaged/Reissue has no window — a lost or damaged document can be + replaced at any point in its validity, unlike Renewal above. */} + {reissuable && onReissue && ( + + + + )} ); } diff --git a/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx b/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx new file mode 100644 index 000000000..b4b124252 --- /dev/null +++ b/apps/portal/src/app/features/licensing/components/PickupSchedulingPanel.tsx @@ -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 ( + + + + + {t('pickup.title')} + + + + {scheduledDate ? ( + + + {t('pickup.scheduledFor', { + date: scheduledDate, + period: + scheduledPeriod === 'AFTERNOON' + ? t('pickup.afternoon') + : t('pickup.morning'), + })} + + + {t('pickup.setByOffice')} + + + ) : ( + + {t('pickup.awaitingSchedule')} + + )} + + ); +} + +export default PickupSchedulingPanel; diff --git a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx index 19b83b655..2a75eeae5 100644 --- a/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx +++ b/apps/portal/src/app/features/licensing/pages/LicenseApplicationPage.tsx @@ -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(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]} + {detail?.issuedLicenseStatus === "SUPERSEDED" && ( + + {t("licensing.certificateSuperseded", "Certificate superseded")} + + )}
@@ -759,6 +778,17 @@ export function LicenseApplicationPage() { )} + {config.licenseType.requiresIssuanceScheduling && + (application.status === "PAYMENT_CONFIRMED" || + application.status === "SCHEDULED") && ( + + + + )} + {showSummary && editableWhileSubmitted && ( = { + NEW: 'applications.table.kindNew', + RENEWAL: 'applications.table.kindRenewal', + REISSUE: 'applications.table.kindReissue', +}; + +const KIND_COLOR: Record = { + 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 }) => ( - - {localized(row.original.licenseType?.name, deps.language) || '—'} - + + + {localized(row.original.licenseType?.name, deps.language) || '—'} + + {row.original.kind !== 'NEW' && ( + + {t(KIND_LABEL[row.original.kind])} + + )} + {row.original.applicationNumber} diff --git a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx index d3e905816..59f73dfaa 100644 --- a/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx +++ b/apps/portal/src/app/features/licensing/pages/MyApplicationsPage/index.tsx @@ -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(null); + const [kindFilter, setKindFilter] = useState(null); const [dateFrom, setDateFrom] = useState(''); const [dateTo, setDateTo] = useState(''); const [bucketFilter, setBucketFilter] = useState(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} /> +