mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-29 15:18:12 +00:00
Merge branch 'WorkflowChange' of github.com:Tria-plc/emaui into WorkflowChange
This commit is contained in:
39
.env.example
Normal file
39
.env.example
Normal 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
2
.gitignore
vendored
@@ -13,6 +13,8 @@ coverage/
|
||||
# env
|
||||
.env
|
||||
.env.*
|
||||
# ...but the checked-in template must survive that rule.
|
||||
!.env.example
|
||||
|
||||
# logs
|
||||
*.log
|
||||
|
||||
48
README.md
48
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/<feature-name>/
|
||||
├── types/ # TypeScript interfaces
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 ?? ""}
|
||||
|
||||
@@ -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);
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
),
|
||||
},
|
||||
];
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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'),
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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 }} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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: "የበላይ ኃላፊ",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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],
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
// },
|
||||
// },
|
||||
|
||||
@@ -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`,
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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]);
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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: 'ተገኝቷል',
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 },
|
||||
];
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal file
121
libs/auth/src/lib/pages/FaydaCallbackPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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">
|
||||
|
||||
87
libs/auth/src/lib/utils/fayda-session.ts
Normal file
87
libs/auth/src/lib/utils/fayda-session.ts
Normal 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),
|
||||
};
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user