Merge branch 'WorkflowChange' into logestic_chnage

This commit is contained in:
Nati Nigussie
2026-08-27 11:23:14 +03:00
committed by GitHub
92 changed files with 5309 additions and 1041 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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);
}

View File

@@ -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 }),
});

View File

@@ -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();

View File

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

View File

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

View File

@@ -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>
);
},
);