mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-30 12:18:12 +00:00
Merge branch 'WorkflowChange' into logestic_chnage
This commit is contained in:
@@ -1,10 +1,11 @@
|
||||
import { fetchBaseQuery, type BaseQueryFn } from '@reduxjs/toolkit/query/react';
|
||||
import type { FetchArgs, FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { resolveSessionContext } from '../session';
|
||||
import { fetchBaseQuery, type BaseQueryFn } from "@reduxjs/toolkit/query/react";
|
||||
import type { FetchArgs, FetchBaseQueryError } from "@reduxjs/toolkit/query";
|
||||
import { resolveSessionContext } from "../session";
|
||||
|
||||
export const BASE_API_URL =
|
||||
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
|
||||
'http://localhost:3000/api';
|
||||
(import.meta as { env?: Record<string, string> }).env?.[
|
||||
"VITE_BASE_API_URL"
|
||||
] ?? "http://localhost:3000/api";
|
||||
|
||||
let _onTokenExpired: (() => Promise<string>) | null = null;
|
||||
let _onAuthFailure: (() => void) | null = null;
|
||||
@@ -28,7 +29,7 @@ export const baseQueryWithReauth: BaseQueryFn<
|
||||
const { token, sessionHeaders } = resolveSessionContext(
|
||||
api.getState() as { auth?: { token?: string } },
|
||||
);
|
||||
if (token) headers.set('Authorization', `Bearer ${token}`);
|
||||
if (token) headers.set("Authorization", `Bearer ${token}`);
|
||||
Object.entries(sessionHeaders).forEach(([k, v]) => headers.set(k, v));
|
||||
return headers;
|
||||
},
|
||||
|
||||
@@ -6,6 +6,7 @@ import type {
|
||||
ApplicationPayment,
|
||||
ApplicationStaff,
|
||||
Attachment,
|
||||
Department,
|
||||
DocumentRequirement,
|
||||
FormSchemaPalette,
|
||||
FormSectionConfig,
|
||||
@@ -21,11 +22,14 @@ import type {
|
||||
AssignableOfficer,
|
||||
DocumentDecision,
|
||||
DocumentReview,
|
||||
EligibleExam,
|
||||
ExportResult,
|
||||
LicenseTemplate,
|
||||
Paginated,
|
||||
QueueCounts,
|
||||
QueueFilter,
|
||||
Rank,
|
||||
RankCertificateCategory,
|
||||
RemarkTargetType,
|
||||
SavedQueueView,
|
||||
SchemaIssue,
|
||||
@@ -71,6 +75,8 @@ const TAGS = [
|
||||
'SavedView',
|
||||
'LicenseTemplate',
|
||||
'DocumentRequirement',
|
||||
'Department',
|
||||
'Rank',
|
||||
] as const;
|
||||
|
||||
const listTag = (type: (typeof TAGS)[number]) => ({ type, id: 'LIST' }) as const;
|
||||
@@ -253,6 +259,75 @@ export const licensingApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('DocumentRequirement')]),
|
||||
}),
|
||||
|
||||
// --------------------------------------------------- departments & ranks
|
||||
/** Every department, for the admin editor. */
|
||||
getDepartments: builder.query<Paginated<Department>, void>({
|
||||
query: () => ({ url: '/departments' }),
|
||||
providesTags: () => [listTag('Department')],
|
||||
}),
|
||||
|
||||
/** Active departments only — the applicant-facing picker. */
|
||||
getActiveDepartments: builder.query<Department[], void>({
|
||||
query: () => ({ url: '/departments/active/list' }),
|
||||
providesTags: () => [listTag('Department')],
|
||||
}),
|
||||
|
||||
createDepartment: builder.mutation<
|
||||
Department,
|
||||
Partial<Department> & { code: string; name: Department['name'] }
|
||||
>({
|
||||
query: (body) => ({ url: '/departments', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||
}),
|
||||
|
||||
updateDepartment: builder.mutation<Department, { id: string } & Partial<Department>>({
|
||||
query: ({ id, ...body }) => ({ url: `/departments/${id}`, method: 'PUT', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||
}),
|
||||
|
||||
deleteDepartment: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/departments/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Department')]),
|
||||
}),
|
||||
|
||||
/** Every rank, for the admin editor to filter/group by department client-side. */
|
||||
getRanks: builder.query<Paginated<Rank>, void>({
|
||||
query: () => ({ url: '/ranks' }),
|
||||
providesTags: () => [listTag('Rank')],
|
||||
}),
|
||||
|
||||
/** One department's ladder for a category, ordered — the applicant wizard's rank picker. */
|
||||
getRankLadder: builder.query<
|
||||
Rank[],
|
||||
{ departmentId: string; certificateCategory: RankCertificateCategory }
|
||||
>({
|
||||
query: (params) => ({ url: '/ranks/ladder', params }),
|
||||
providesTags: () => [listTag('Rank')],
|
||||
}),
|
||||
|
||||
createRank: builder.mutation<
|
||||
Rank,
|
||||
Partial<Rank> & {
|
||||
departmentId: string;
|
||||
certificateCategory: RankCertificateCategory;
|
||||
key: string;
|
||||
name: Rank['name'];
|
||||
}
|
||||
>({
|
||||
query: (body) => ({ url: '/ranks', method: 'POST', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||
}),
|
||||
|
||||
updateRank: builder.mutation<Rank, { id: string } & Partial<Rank>>({
|
||||
query: ({ id, ...body }) => ({ url: `/ranks/${id}`, method: 'PUT', body }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||
}),
|
||||
|
||||
deleteRank: builder.mutation<unknown, string>({
|
||||
query: (id) => ({ url: `/ranks/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error) => (error ? [] : [listTag('Rank')]),
|
||||
}),
|
||||
|
||||
// -------------------------------------------------------- application
|
||||
createApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -575,6 +650,15 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Exam sittings valid for this application's rank — what the
|
||||
* schedule-exam picker offers, instead of every exam in the system.
|
||||
*/
|
||||
getEligibleExams: builder.query<EligibleExam[], string>({
|
||||
query: (id) => ({ url: `/license-application-review/${id}/eligible-exams` }),
|
||||
providesTags: (_r, _e, id) => [itemTag('LicenseApplication', id)],
|
||||
}),
|
||||
|
||||
/** Places a candidate who has paid the examination fee into a sitting. */
|
||||
scheduleExam: builder.mutation<
|
||||
LicenseApplication,
|
||||
@@ -641,6 +725,8 @@ export const licensingApi = baseApi
|
||||
LicenseTemplate,
|
||||
{
|
||||
licenseTypeId: string;
|
||||
/** Scopes the draft to one rank's certificate. Omit for the type's default design. */
|
||||
rankId?: string | null;
|
||||
name: string;
|
||||
hbsSource?: string;
|
||||
pageOptions?: TemplatePageOptions;
|
||||
@@ -654,6 +740,7 @@ export const licensingApi = baseApi
|
||||
LicenseTemplate,
|
||||
{
|
||||
id: string;
|
||||
rankId?: string | null;
|
||||
name?: string;
|
||||
hbsSource?: string;
|
||||
pageOptions?: TemplatePageOptions;
|
||||
@@ -839,6 +926,23 @@ export const licensingApi = baseApi
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
/**
|
||||
* Starts the review: the team leader hands the file to an employee.
|
||||
* `assign` above only re-points an application already in flight.
|
||||
*/
|
||||
assignReviewer: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; officerId: string; remark?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
url: `/license-application-review/${id}/assign-reviewer`,
|
||||
method: 'POST',
|
||||
body,
|
||||
}),
|
||||
invalidatesTags: (_r, error, { id }) =>
|
||||
error ? [] : [itemTag('LicenseApplication', id), listTag('ApplicationQueue')],
|
||||
}),
|
||||
|
||||
holdApplication: builder.mutation<
|
||||
LicenseApplication,
|
||||
{ id: string; reason: string }
|
||||
@@ -984,6 +1088,16 @@ export const {
|
||||
useCreateDocumentRequirementMutation,
|
||||
useUpdateDocumentRequirementMutation,
|
||||
useDeleteDocumentRequirementMutation,
|
||||
useGetDepartmentsQuery,
|
||||
useGetActiveDepartmentsQuery,
|
||||
useCreateDepartmentMutation,
|
||||
useUpdateDepartmentMutation,
|
||||
useDeleteDepartmentMutation,
|
||||
useGetRanksQuery,
|
||||
useGetRankLadderQuery,
|
||||
useCreateRankMutation,
|
||||
useUpdateRankMutation,
|
||||
useDeleteRankMutation,
|
||||
useUpdateLicenseValidityMutation,
|
||||
useGetLicenseTypeRequirementsQuery,
|
||||
useCreateApplicationMutation,
|
||||
@@ -1043,6 +1157,7 @@ export const {
|
||||
useApproveDocumentsMutation,
|
||||
useFinalApproveMutation,
|
||||
useRejectApplicationMutation,
|
||||
useGetEligibleExamsQuery,
|
||||
useScheduleExamMutation,
|
||||
useRecordExamOutcomeMutation,
|
||||
useRetakeExamMutation,
|
||||
|
||||
@@ -3,6 +3,7 @@ import { resolveTokenFromStorage } from '../../session';
|
||||
import type {
|
||||
Bilingual,
|
||||
FamilyKind,
|
||||
FieldCondition,
|
||||
FormFieldConfig,
|
||||
FormSectionConfig,
|
||||
LicenseApplication,
|
||||
@@ -68,6 +69,7 @@ export const STATUS_LABELS: Record<LicenseStatus, string> = {
|
||||
REVIEW_REPORTED: 'Review Reported',
|
||||
INSPECTION_COMPLETED: 'Inspection Completed',
|
||||
INSPECTION_REPORTED: 'Inspection Reported',
|
||||
INSPECTION_FAILED: 'Inspection Failed',
|
||||
APPROVED: 'Approved',
|
||||
REJECTED: 'Rejected',
|
||||
ON_HOLD: 'On Hold',
|
||||
@@ -99,6 +101,8 @@ export const STATUS_COLORS: Record<LicenseStatus, string> = {
|
||||
REVIEW_REPORTED: 'orange',
|
||||
INSPECTION_COMPLETED: 'cyan',
|
||||
INSPECTION_REPORTED: 'orange',
|
||||
// Orange, not red: recoverable — a re-inspection can still pass.
|
||||
INSPECTION_FAILED: 'orange',
|
||||
APPROVED: 'teal',
|
||||
REJECTED: 'red',
|
||||
ON_HOLD: 'gray',
|
||||
@@ -134,6 +138,8 @@ export const STATUS_PROGRESS: Record<LicenseStatus, number> = {
|
||||
REVIEW_REPORTED: 50,
|
||||
INSPECTION_COMPLETED: 65,
|
||||
INSPECTION_REPORTED: 70,
|
||||
// A re-inspection returns to the pending step, so no further along than it.
|
||||
INSPECTION_FAILED: 55,
|
||||
APPROVED: 75,
|
||||
// Parked, so it keeps the progress of wherever it was held from.
|
||||
ON_HOLD: 45,
|
||||
@@ -187,6 +193,7 @@ export const APPLICANT_NAME_TYPE_KEYS = [
|
||||
'CERTIFICATE_OF_PROFICIENCY',
|
||||
'VESSEL_REGISTRATION',
|
||||
'VESSEL_OWNERSHIP_TRANSFER',
|
||||
'ENDORSEMENT_SEAFARER',
|
||||
'ENDORSEMENT_COC',
|
||||
'ENDORSEMENT_GOC',
|
||||
];
|
||||
@@ -198,6 +205,7 @@ const FAMILY_KIND_BY_KEY: Partial<Record<string, FamilyKind>> = {
|
||||
BTC_BASIC_TRAINING: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_COMPETENCY: 'CERTIFICATE',
|
||||
CERTIFICATE_OF_PROFICIENCY: 'CERTIFICATE',
|
||||
ENDORSEMENT_SEAFARER: 'CERTIFICATE',
|
||||
ENDORSEMENT_COC: 'CERTIFICATE',
|
||||
ENDORSEMENT_GOC: 'CERTIFICATE',
|
||||
FREIGHT_FORWARDER: 'LOGISTICS_LICENSE',
|
||||
@@ -381,6 +389,10 @@ const ERROR_MESSAGES: Record<string, string> = {
|
||||
application_not_awaiting_inspection:
|
||||
'This application is not waiting for an inspection.',
|
||||
inspection_already_completed: 'This inspection has already been recorded.',
|
||||
inspection_not_yet_due:
|
||||
'Inspection results can be recorded after the scheduled inspection date and time.',
|
||||
inspection_not_passed:
|
||||
'Approval requires a passed inspection. Schedule a re-inspection or request corrections.',
|
||||
license_type_inactive: 'This licence type is not currently accepting applications.',
|
||||
};
|
||||
|
||||
@@ -565,15 +577,33 @@ export function validateSections(
|
||||
return errors;
|
||||
}
|
||||
|
||||
/** Evaluates a config condition against the current form answers. */
|
||||
/**
|
||||
* Evaluates a config condition against the current form answers.
|
||||
*
|
||||
* Mirrors the server's `ApplicationValidationService.conditionHolds` —
|
||||
* `anyOf` holds when any listed sub-condition holds, needed for an answer
|
||||
* that can live on one of several mutually-exclusive fields (e.g. a CoP rank
|
||||
* split by department).
|
||||
*/
|
||||
interface ConditionLike {
|
||||
field?: string;
|
||||
equals?: unknown;
|
||||
notEquals?: unknown;
|
||||
in?: (string | number)[];
|
||||
isSet?: boolean;
|
||||
/** Holds when ANY listed sub-condition holds — see FieldCondition.anyOf. */
|
||||
anyOf?: ConditionLike[];
|
||||
}
|
||||
|
||||
export function conditionHolds(
|
||||
condition:
|
||||
| { field: string; equals?: unknown; notEquals?: unknown; in?: (string | number)[]; isSet?: boolean }
|
||||
| undefined
|
||||
| null,
|
||||
condition: FieldCondition | undefined | null,
|
||||
formData: Record<string, Record<string, unknown>>,
|
||||
): boolean {
|
||||
if (!condition?.field) return true;
|
||||
if (!condition) return true;
|
||||
if (condition.anyOf) {
|
||||
return condition.anyOf.some((sub) => conditionHolds(sub, formData));
|
||||
}
|
||||
if (!condition.field) return true;
|
||||
const value = condition.field
|
||||
.split('.')
|
||||
.reduce<unknown>(
|
||||
|
||||
@@ -33,6 +33,10 @@ export type LicenseStatus =
|
||||
| "INSPECTION_COMPLETED"
|
||||
// Inspector filed the result; parked with the team leader for a decision.
|
||||
| "INSPECTION_REPORTED"
|
||||
// The inspection was conducted and failed. Approval and issuance are
|
||||
// unreachable until a re-inspection passes; the officer chooses between a
|
||||
// repeat visit, an adjustment round, and rejection.
|
||||
| "INSPECTION_FAILED"
|
||||
| "APPROVED"
|
||||
| "REJECTED"
|
||||
| "ON_HOLD"
|
||||
@@ -71,11 +75,18 @@ export type FormFieldType =
|
||||
| "TIN";
|
||||
|
||||
export interface FieldCondition {
|
||||
field: string;
|
||||
/** Omitted when `anyOf` is used instead — see below. */
|
||||
field?: string;
|
||||
equals?: string | number | boolean;
|
||||
notEquals?: string | number | boolean;
|
||||
in?: (string | number)[];
|
||||
isSet?: boolean;
|
||||
/**
|
||||
* Alternative to a single-field check: holds when ANY listed condition
|
||||
* holds. `field`/`equals`/etc are ignored when this is present. Mirrors
|
||||
* the server's `FieldCondition` (form-schema.type.ts).
|
||||
*/
|
||||
anyOf?: FieldCondition[];
|
||||
}
|
||||
|
||||
export interface FormFieldConfig {
|
||||
@@ -578,19 +589,50 @@ export interface TemplateFieldPlacement {
|
||||
/** Variable rendered here, or null when the block carries literal `text`. */
|
||||
variable: string | null;
|
||||
text?: string;
|
||||
/** Renders as `<img>` when "image" — see TemplateVariable.kind. */
|
||||
type?: "text" | "image";
|
||||
xPct: number;
|
||||
yPct: number;
|
||||
widthPct: number;
|
||||
fontSize?: number;
|
||||
fontWeight?: "normal" | "bold";
|
||||
align?: "left" | "center" | "right";
|
||||
fontStyle?: "normal" | "italic";
|
||||
align?: "left" | "center" | "right" | "justify";
|
||||
color?: string;
|
||||
}
|
||||
|
||||
/** A certificate design authored in the backoffice. */
|
||||
/** An STCW seafarer department (Deck, Engine, Catering), backoffice-managed. */
|
||||
export interface Department {
|
||||
id: string;
|
||||
/** Matches the ESeafarerDepartment value stored elsewhere, e.g. "DECK". */
|
||||
code: string;
|
||||
name: Bilingual;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export type RankCertificateCategory = "COC" | "COP";
|
||||
|
||||
/** One rung of a CoC/CoP ladder for a department. */
|
||||
export interface Rank {
|
||||
id: string;
|
||||
departmentId: string;
|
||||
certificateCategory: RankCertificateCategory;
|
||||
/** Value stored on License.rank / Certification.rankKey, e.g. "CHIEF_MATE". */
|
||||
key: string;
|
||||
name: Bilingual;
|
||||
/** Rung position within its department+category ladder. 0 is the entry rank. */
|
||||
ladderOrder: number;
|
||||
sortOrder: number;
|
||||
isActive: boolean;
|
||||
}
|
||||
|
||||
export interface LicenseTemplate {
|
||||
id: string;
|
||||
licenseTypeId: string;
|
||||
/** Scopes this design to one rank's certificate. Null = the type's default. */
|
||||
rankId?: string | null;
|
||||
name: string;
|
||||
version: number;
|
||||
hbsSource: string;
|
||||
@@ -617,6 +659,8 @@ export interface LicenseTemplate {
|
||||
export interface TemplateVariable {
|
||||
key: string;
|
||||
label: string;
|
||||
/** "image" means the value is a data URI to place as `<img>`, not text. */
|
||||
kind?: "text" | "image";
|
||||
}
|
||||
|
||||
export interface Paginated<T> {
|
||||
@@ -703,3 +747,13 @@ export interface IssuedLicense {
|
||||
verificationCode: string;
|
||||
certificateFileKey: string | null;
|
||||
}
|
||||
|
||||
/** One sitting offered to a claimed examined-certificate application, scoped to its rank. */
|
||||
export interface EligibleExam {
|
||||
id: string;
|
||||
title: { en: string; am: string };
|
||||
date: string;
|
||||
venue: string;
|
||||
status: string;
|
||||
certification?: { id: string; name: { en: string; am: string }; rankKey: string | null };
|
||||
}
|
||||
|
||||
@@ -36,6 +36,11 @@ export const seafarerRegistrationApi = baseApi
|
||||
invalidatesTags: (_r, error) => (error ? [] : [LIST]),
|
||||
}),
|
||||
|
||||
cancelSeafarerRegistration: builder.mutation<void, string>({
|
||||
query: (id) => ({ url: `/seafarer-registrations/${id}`, method: 'DELETE' }),
|
||||
invalidatesTags: (_r, error, id) => (error ? [] : [LIST, item(id)]),
|
||||
}),
|
||||
|
||||
saveSeafarerRegistration: builder.mutation<
|
||||
SeafarerRegistration,
|
||||
{ id: string; body: SaveSeafarerRegistration }
|
||||
@@ -108,6 +113,7 @@ export const seafarerRegistrationApi = baseApi
|
||||
export const {
|
||||
useGetMySeafarerRegistrationQuery,
|
||||
useStartSeafarerRegistrationMutation,
|
||||
useCancelSeafarerRegistrationMutation,
|
||||
useSaveSeafarerRegistrationMutation,
|
||||
useSubmitSeafarerRegistrationMutation,
|
||||
useListSeafarerRegistrationsQuery,
|
||||
|
||||
@@ -64,13 +64,28 @@ export const PHYSICAL_BOUNDS = {
|
||||
weightKg: { min: 30, max: 250 },
|
||||
} as const;
|
||||
|
||||
/**
|
||||
* How the platform spells Ethiopia in the `nationality` free-text field
|
||||
* (CountrySelect's country name — matches the API's ETHIOPIAN_NATIONALITY).
|
||||
*/
|
||||
export const ETHIOPIAN_NATIONALITY = 'Ethiopia';
|
||||
|
||||
/** Whether a declared nationality is Ethiopian — drives National ID vs Passport requirements. */
|
||||
export function isEthiopianNationality(nationality: string | null | undefined): boolean {
|
||||
return nationality === ETHIOPIAN_NATIONALITY;
|
||||
}
|
||||
|
||||
/** Upload slots, keyed as the API's submission check expects them. */
|
||||
export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
key: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
/** `'passport'`: required only once a passport number is declared. */
|
||||
required: boolean | 'passport';
|
||||
/**
|
||||
* `'passport'`: required once a passport number is declared (always true
|
||||
* for non-Ethiopians, who must declare one). `'ethiopian'`: required only
|
||||
* for applicants who declared Ethiopian nationality.
|
||||
*/
|
||||
required: boolean | 'passport' | 'ethiopian';
|
||||
accept?: string;
|
||||
}[] = [
|
||||
{
|
||||
@@ -80,7 +95,7 @@ export const SEAFARER_REGISTRATION_DOCUMENTS: {
|
||||
required: true,
|
||||
accept: 'image/jpeg,image/png',
|
||||
},
|
||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: true },
|
||||
{ key: 'nationalId', name: 'National ID (Fayda) or Kebele ID', required: 'ethiopian' },
|
||||
{ key: 'passport', name: 'Passport Copy', required: 'passport' },
|
||||
{ key: 'graduation', name: 'Educational Certificate', required: false },
|
||||
{
|
||||
@@ -210,14 +225,22 @@ const OPTION_LABELS: Partial<Record<keyof SeafarerRegistrationAnswers, { value:
|
||||
bloodType: BLOOD_TYPE_OPTIONS,
|
||||
};
|
||||
|
||||
/** Display value for one answer: option label for enums, "—" when blank. */
|
||||
/**
|
||||
* Display value for one answer: option label for enums, "—" when blank.
|
||||
*
|
||||
* `departmentOptions` overrides the hardcoded `DEPARTMENT_OPTIONS` fallback
|
||||
* for the `department` field — the live list from `GET /departments`, so a
|
||||
* department added in the backoffice after this constants file was written
|
||||
* still gets its name instead of falling back to the raw code.
|
||||
*/
|
||||
export function displaySeafarerAnswer(
|
||||
field: keyof SeafarerRegistrationAnswers,
|
||||
value: unknown,
|
||||
departmentOptions?: { value: string; label: string }[],
|
||||
): string {
|
||||
if (value === null || value === undefined || value === '') return '—';
|
||||
if (typeof value === 'boolean') return value ? 'Yes' : 'No';
|
||||
const options = OPTION_LABELS[field];
|
||||
const options = field === 'department' && departmentOptions ? departmentOptions : OPTION_LABELS[field];
|
||||
if (options) return options.find((o) => o.value === value)?.label ?? String(value);
|
||||
return String(value);
|
||||
}
|
||||
|
||||
@@ -73,8 +73,8 @@ async function runRefresh(): Promise<string> {
|
||||
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
|
||||
// 404s, which the caller would turn into a silent logout.
|
||||
const response = await fetch(`${BASE_API_URL}/auth/refresh-token`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ refreshToken }),
|
||||
});
|
||||
|
||||
|
||||
@@ -15,7 +15,7 @@ import {
|
||||
import { TimeInput } from '@mantine/dates';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic';
|
||||
import { DayPicker as GregorianDayPicker } from '@daypicker/react';
|
||||
import { DayPicker as GregorianDayPicker, type Matcher } from '@daypicker/react';
|
||||
import { IconCalendarEvent } from '@tabler/icons-react';
|
||||
import '@daypicker/react/dist/style.css';
|
||||
import './AmharicDatePicker.css';
|
||||
@@ -131,6 +131,11 @@ export interface AmharicDatePickerProps {
|
||||
/** Show a time-of-day field alongside the calendar. Off by default —
|
||||
* most callers only need a calendar day. */
|
||||
withTime?: boolean;
|
||||
/** Earliest/latest selectable day. Accepts a Date or a value in the same
|
||||
* wire format as `value`. Days outside the range are disabled in both
|
||||
* calendars. */
|
||||
minDate?: Date | string;
|
||||
maxDate?: Date | string;
|
||||
/** Wire format for `value`/`onChange`: a full ISO-8601 instant (default,
|
||||
* what most backend date fields expect) or a bare `yyyy-mm-dd` calendar
|
||||
* date (what filter query params and plain `date: string` DTO fields
|
||||
@@ -151,6 +156,8 @@ export function AmharicDatePicker({
|
||||
onBlur,
|
||||
w,
|
||||
withTime = false,
|
||||
minDate,
|
||||
maxDate,
|
||||
dateFormat = 'iso',
|
||||
}: AmharicDatePickerProps) {
|
||||
const { t, i18n } = useTranslation();
|
||||
@@ -161,6 +168,21 @@ export function AmharicDatePicker({
|
||||
|
||||
const selected = parseWireValue(value, dateFormat, withTime);
|
||||
|
||||
const asDate = (limit: Date | string | undefined) =>
|
||||
limit instanceof Date ? limit : parseWireValue(limit, dateFormat, withTime);
|
||||
const min = asDate(minDate);
|
||||
const max = asDate(maxDate);
|
||||
const outOfRange: Matcher[] = [
|
||||
...(min ? [{ before: min }] : []),
|
||||
...(max ? [{ after: max }] : []),
|
||||
];
|
||||
// Compared as calendar days — the limits carry a midnight time-of-day, so
|
||||
// an instant comparison would call today "after" a max of today.
|
||||
const todayKey = formatPlainDate(new Date());
|
||||
const todayOutOfRange =
|
||||
(!!min && todayKey < formatPlainDate(min)) ||
|
||||
(!!max && todayKey > formatPlainDate(max));
|
||||
|
||||
const dateLabel = selected
|
||||
? calendarType === 'EN'
|
||||
? selected.toLocaleDateString('en-US', {
|
||||
@@ -266,6 +288,7 @@ export function AmharicDatePicker({
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
numerals="latn"
|
||||
captionLayout="dropdown"
|
||||
disabled={outOfRange}
|
||||
formatters={ETH_FORMATTERS}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
||||
@@ -281,6 +304,7 @@ export function AmharicDatePicker({
|
||||
startMonth={YEAR_DROPDOWN_START}
|
||||
endMonth={YEAR_DROPDOWN_END}
|
||||
captionLayout="dropdown"
|
||||
disabled={outOfRange}
|
||||
onSelect={(date: Date | undefined) => {
|
||||
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
|
||||
if (!withTime) close();
|
||||
@@ -389,6 +413,7 @@ export function AmharicDatePicker({
|
||||
<Button
|
||||
variant="light"
|
||||
size="xs"
|
||||
disabled={todayOutOfRange}
|
||||
onClick={() => {
|
||||
onChange?.(formatWireValue(new Date(), dateFormat, withTime));
|
||||
close();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
Tooltip,
|
||||
TextInput,
|
||||
UnstyledButton,
|
||||
rem,
|
||||
@@ -37,6 +38,9 @@ export function BilingualInput({
|
||||
...rest
|
||||
}: BilingualInputProps) {
|
||||
const [lang, setLang] = useState<'en' | 'am'>('en');
|
||||
const otherLang = lang === 'en' ? 'am' : 'en';
|
||||
const otherLangName = otherLang === 'en' ? 'English' : 'Amharic';
|
||||
const otherIsEmpty = !value[otherLang]?.trim();
|
||||
|
||||
const toggle = () => setLang((l) => (l === 'en' ? 'am' : 'en'));
|
||||
|
||||
@@ -48,37 +52,59 @@ export function BilingualInput({
|
||||
value={value[lang]}
|
||||
onChange={(e) => onChange({ ...value, [lang]: e.currentTarget.value })}
|
||||
rightSection={
|
||||
<UnstyledButton
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${lang === 'en' ? 'Amharic' : 'English'}`}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(28),
|
||||
height: rem(20),
|
||||
borderRadius: rem(4),
|
||||
fontSize: rem(10),
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
background:
|
||||
lang === 'en'
|
||||
? 'var(--mantine-color-blue-1)'
|
||||
: 'var(--mantine-color-teal-1)',
|
||||
color:
|
||||
lang === 'en'
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-teal-7)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 150ms ease',
|
||||
}}
|
||||
<Tooltip
|
||||
label={`Switch to ${otherLangName}${otherIsEmpty ? ' — empty' : ''}`}
|
||||
withArrow
|
||||
>
|
||||
{lang === 'en' ? 'EN' : 'AM'}
|
||||
</UnstyledButton>
|
||||
<UnstyledButton
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${otherLangName}`}
|
||||
style={{
|
||||
position: 'relative',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
width: rem(32),
|
||||
height: rem(24),
|
||||
borderRadius: rem(4),
|
||||
fontSize: rem(11),
|
||||
fontWeight: 700,
|
||||
letterSpacing: '0.05em',
|
||||
background:
|
||||
lang === 'en'
|
||||
? 'var(--mantine-color-blue-1)'
|
||||
: 'var(--mantine-color-teal-1)',
|
||||
color:
|
||||
lang === 'en'
|
||||
? 'var(--mantine-color-blue-7)'
|
||||
: 'var(--mantine-color-teal-7)',
|
||||
cursor: 'pointer',
|
||||
transition: 'background 150ms ease',
|
||||
}}
|
||||
>
|
||||
{lang === 'en' ? 'EN' : 'AM'}
|
||||
{otherIsEmpty && (
|
||||
<span
|
||||
aria-hidden
|
||||
title={`${otherLangName} text is missing`}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: -2,
|
||||
right: -2,
|
||||
width: rem(7),
|
||||
height: rem(7),
|
||||
borderRadius: '50%',
|
||||
background: 'var(--mantine-color-red-6)',
|
||||
border: '1px solid var(--mantine-color-body)',
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</UnstyledButton>
|
||||
</Tooltip>
|
||||
}
|
||||
styles={{
|
||||
input: {
|
||||
paddingRight: rem(42),
|
||||
paddingRight: rem(46),
|
||||
},
|
||||
}}
|
||||
{...rest}
|
||||
|
||||
@@ -14,6 +14,7 @@ import {
|
||||
IconLogout,
|
||||
IconUserCircle,
|
||||
} from '@tabler/icons-react';
|
||||
import type { ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { LanguageSwitcher } from './LanguageSwitcher';
|
||||
import { ColorSchemeToggle } from './ColorSchemeToggle';
|
||||
@@ -36,6 +37,17 @@ interface AppHeaderProps {
|
||||
supportedLanguages: readonly string[];
|
||||
onNotificationsClick?: () => void;
|
||||
notificationCount?: number;
|
||||
/**
|
||||
* Rendered at the far left. The sidebar layout carries the brand in the
|
||||
* sidebar itself; the top-bar layout has no sidebar, so it passes the brand
|
||||
* here rather than leaving the chrome unbranded.
|
||||
*/
|
||||
brand?: ReactNode;
|
||||
/**
|
||||
* Breakpoint from which the burger is hidden. The top-bar layout only needs
|
||||
* it on small screens, where the drawer replaces the nav strip.
|
||||
*/
|
||||
burgerHiddenFrom?: string;
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
@@ -50,17 +62,21 @@ export function AppHeader({
|
||||
supportedLanguages,
|
||||
onNotificationsClick,
|
||||
notificationCount,
|
||||
brand,
|
||||
burgerHiddenFrom,
|
||||
}: AppHeaderProps) {
|
||||
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
|
||||
|
||||
return (
|
||||
<Group h="100%" px="lg" justify="space-between" wrap="nowrap">
|
||||
<Group gap="md" wrap="nowrap">
|
||||
{brand}
|
||||
{/* Hamburger — styled like user-management Top.tsx */}
|
||||
{/* The Burger itself owns the click so the control is a real, keyboard
|
||||
reachable <button>; the Box is chrome only. It previously wrapped a
|
||||
no-op button, which no keyboard user could operate. */}
|
||||
<Box
|
||||
hiddenFrom={burgerHiddenFrom}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
|
||||
import { forwardRef } from 'react';
|
||||
import { IconChevronDown } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { NavItem } from './AppSidebar';
|
||||
@@ -23,7 +24,9 @@ interface AppTopNavProps {
|
||||
* scrolling strip, so with twenty-odd of them most were off-screen and the
|
||||
* grouping that the sidebar already had was thrown away. Here each section
|
||||
* collapses to a single labelled dropdown, which fits and keeps the same
|
||||
* information architecture as the sidebar.
|
||||
* information architecture as the sidebar. Sections that still do not fit
|
||||
* scroll horizontally rather than dropping off the edge — under ~1100px the
|
||||
* last one or two were simply unreachable.
|
||||
*/
|
||||
export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
|
||||
const { t } = useTranslation();
|
||||
@@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps)
|
||||
wrap="nowrap"
|
||||
role="navigation"
|
||||
aria-label={t('nav.primary', 'Primary')}
|
||||
style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }}
|
||||
>
|
||||
{sections.map((section, index) => {
|
||||
// An unlabelled leading block (Dashboard) is a plain link, not a menu.
|
||||
@@ -148,40 +152,52 @@ interface TopNavButtonProps {
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
function TopNavButton({
|
||||
label,
|
||||
active,
|
||||
badge,
|
||||
soon,
|
||||
withChevron,
|
||||
onClick,
|
||||
}: TopNavButtonProps) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
onClick={onClick}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(6),
|
||||
padding: `0 ${rem(14)}`,
|
||||
height: '100%',
|
||||
borderBottom: '2px solid',
|
||||
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent',
|
||||
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: soon ? 0.55 : 1,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{badge !== null && badge !== undefined && (
|
||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
||||
</UnstyledButton>
|
||||
);
|
||||
}
|
||||
/**
|
||||
* `Menu.Target` positions its dropdown against the ref it passes to its child,
|
||||
* so a plain function component here left every section menu anchored at the
|
||||
* top-left of the viewport, covering the header instead of opening under the
|
||||
* button that was clicked. The rest props carry Menu's own click and aria
|
||||
* handling onto the real button.
|
||||
*/
|
||||
const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>(
|
||||
function TopNavButton(
|
||||
{ label, active, badge, soon, withChevron, onClick, ...others },
|
||||
ref,
|
||||
) {
|
||||
return (
|
||||
<UnstyledButton
|
||||
ref={ref}
|
||||
onClick={onClick}
|
||||
{...others}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: rem(6),
|
||||
padding: `0 ${rem(14)}`,
|
||||
height: '100%',
|
||||
flexShrink: 0,
|
||||
borderBottom: '2px solid',
|
||||
borderBottomColor: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'transparent',
|
||||
color: active
|
||||
? 'var(--mantine-color-blue-6)'
|
||||
: 'var(--mantine-color-gray-6)',
|
||||
fontWeight: active ? 600 : 500,
|
||||
fontSize: rem(14),
|
||||
whiteSpace: 'nowrap',
|
||||
opacity: soon ? 0.55 : 1,
|
||||
marginBottom: -1,
|
||||
}}
|
||||
>
|
||||
<span>{label}</span>
|
||||
{badge !== null && badge !== undefined && (
|
||||
<Badge size="xs" variant="filled" color="red" radius="sm">
|
||||
{badge}
|
||||
</Badge>
|
||||
)}
|
||||
{withChevron && <IconChevronDown size={14} stroke={2} />}
|
||||
</UnstyledButton>
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user