Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into feature/exam-attempt-domain

This commit is contained in:
nati14575
2026-08-21 21:04:34 +03:00
13 changed files with 370 additions and 106 deletions

View File

@@ -33,14 +33,15 @@ jobs:
BUILD_ENV_FILE: ${{ matrix.build_env_file }} BUILD_ENV_FILE: ${{ matrix.build_env_file }}
DOCKER_BUILDKIT: "1" DOCKER_BUILDKIT: "1"
COMPOSE_DOCKER_CLI_BUILD: "1" COMPOSE_DOCKER_CLI_BUILD: "1"
ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Sync environment from server - name: Sync environment from Env Manager App
run: | run: |
chmod +x scripts/deploy/*.sh chmod +x scripts/deploy/*.sh
./scripts/deploy/sync-env-from-server.sh "${{ matrix.service }}" ./scripts/deploy/sync-env-from-env-manager.sh "${{ matrix.service }}"
- name: Set compose project name - name: Set compose project name
run: | run: |

View File

@@ -1,18 +1,19 @@
FROM node:24-alpine AS deps FROM node:24-alpine AS deps
WORKDIR /app WORKDIR /app
COPY package.json package-lock.json* ./ RUN corepack enable && corepack prepare pnpm@9.0.0 --activate
COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./
COPY local-packages/ ./local-packages/ COPY local-packages/ ./local-packages/
RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \ RUN --mount=type=secret,id=npmrc,target=/root/.npmrc \
npm install --legacy-peer-deps pnpm install --frozen-lockfile
FROM deps AS base FROM deps AS base
COPY . . COPY . .
FROM base AS portal-build FROM base AS portal-build
RUN npm run build:portal RUN pnpm run build:portal
FROM base AS backoffice-build FROM base AS backoffice-build
RUN npm run build:backoffice RUN pnpm run build:backoffice
FROM nginx:1.29-alpine AS portal FROM nginx:1.29-alpine AS portal
COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html COPY --from=portal-build /app/dist/apps/portal /usr/share/nginx/html

View File

@@ -198,20 +198,21 @@ export type VerificationKind = 'medical' | 'sea-service';
*/ */
export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) { export function MedicalVerificationPage({ kind = 'medical' }: { kind?: VerificationKind }) {
const { t } = useTranslation(); const { t } = useTranslation();
const isMedical = kind === 'medical';
const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED'); const [filter, setFilter] = useState<RecordQueueFilter>('SUBMITTED');
const { const {
data: pendingMedical, data: pendingMedical,
isLoading: loadingMedical, isLoading: loadingMedical,
isFetching: fetchingMedical, isFetching: fetchingMedical,
refetch: refetchMedical, refetch: refetchMedical,
} = useGetPendingMedicalQuery(filter); } = useGetPendingMedicalQuery(filter, { skip: !isMedical });
const { const {
data: pendingSeaService, data: pendingSeaService,
isLoading: loadingSeaService, isLoading: loadingSeaService,
isFetching: fetchingSeaService, isFetching: fetchingSeaService,
refetch: refetchSeaService, refetch: refetchSeaService,
} = useGetPendingSeaServiceQuery(filter); } = useGetPendingSeaServiceQuery(filter, { skip: isMedical });
const [verifyMedical, { isLoading: rulingMedical }] = const [verifyMedical, { isLoading: rulingMedical }] =
useVerifyMedicalCertificateMutation(); useVerifyMedicalCertificateMutation();
@@ -372,8 +373,6 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
[rulingSeaService, rule, verifySeaService, showDate, t], [rulingSeaService, rule, verifySeaService, showDate, t],
); );
const isMedical = kind === 'medical';
return ( return (
<Container size="xl" py="md"> <Container size="xl" py="md">
<Title order={3} mb={4}> <Title order={3} mb={4}>
@@ -393,6 +392,8 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
)} )}
</Text> </Text>
{statusFilter}
{isMedical ? ( {isMedical ? (
<AdvancedTable <AdvancedTable
columns={medicalTableColumns} columns={medicalTableColumns}
@@ -408,7 +409,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleMedicalPageSizeChange} onPageSizeChange={handleMedicalPageSizeChange}
refresh={refetchMedical} refresh={refetchMedical}
isLoading={loadingMedical || fetchingMedical} isLoading={loadingMedical || fetchingMedical}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
) : ( ) : (
<AdvancedTable <AdvancedTable
@@ -425,7 +426,7 @@ export function MedicalVerificationPage({ kind = 'medical' }: { kind?: Verificat
onPageSizeChange={handleSeaServicePageSizeChange} onPageSizeChange={handleSeaServicePageSizeChange}
refresh={refetchSeaService} refresh={refetchSeaService}
isLoading={loadingSeaService || fetchingSeaService} isLoading={loadingSeaService || fetchingSeaService}
emptyText={t('recordVerification.emptyText', 'Nothing awaiting verification.')} emptyText={emptyText}
/> />
)} )}

View File

@@ -31,7 +31,6 @@ import {
IconLock, IconLock,
IconMail, IconMail,
IconMoon, IconMoon,
IconPhone,
IconSettings, IconSettings,
IconShieldLock, IconShieldLock,
IconSun, IconSun,
@@ -42,7 +41,7 @@ import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod'; import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod'; import { z } from 'zod';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber } from '@ema-platform/ui'; import { notify, PageHeader, useErrorHandler, passwordSchema as strongPasswordSchema, PasswordRequirements, phoneNumber, PhoneInput } from '@ema-platform/ui';
import { useApiMutation } from '@ema-platform/api'; import { useApiMutation } from '@ema-platform/api';
import { ActiveSessions, setUser } from '@ema-platform/auth'; import { ActiveSessions, setUser } from '@ema-platform/auth';
import type { AuthUser } from '@ema-platform/auth'; import type { AuthUser } from '@ema-platform/auth';
@@ -136,6 +135,9 @@ export function ProfilePage() {
register: registerProfile, register: registerProfile,
handleSubmit: handleProfileSubmit, handleSubmit: handleProfileSubmit,
reset: resetProfile, reset: resetProfile,
watch: watchProfile,
setValue: setValueProfile,
trigger: triggerProfile,
formState: { errors: profileErrors }, formState: { errors: profileErrors },
} = useForm<ProfileValues>({ } = useForm<ProfileValues>({
resolver: zodResolver(profileSchema), resolver: zodResolver(profileSchema),
@@ -372,11 +374,12 @@ export function ProfilePage() {
error={profileErrors.email?.message} error={profileErrors.email?.message}
{...registerProfile('email')} {...registerProfile('email')}
/> />
<TextInput <PhoneInput
label={t('profile.fields.phone')} label={t('profile.fields.phone')}
leftSection={<IconPhone size={18} />} value={watchProfile('phoneNumber') || ''}
onChange={(val) => setValueProfile('phoneNumber', val, { shouldValidate: !!profileErrors.phoneNumber })}
onBlur={() => triggerProfile('phoneNumber')}
error={profileErrors.phoneNumber?.message} error={profileErrors.phoneNumber?.message}
{...registerProfile('phoneNumber')}
/> />
</SimpleGrid> </SimpleGrid>
</div> </div>

View File

@@ -1,5 +1,5 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useMemo, useState } from 'react';
import { AppShell, Drawer } from '@mantine/core'; import { AppShell, Box, Drawer, Group, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { Outlet, useLocation, useNavigate } from 'react-router-dom'; import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
@@ -26,6 +26,14 @@ const BADGE_POLL_MS = 60_000;
const HEADER_HEIGHT = 116; const HEADER_HEIGHT = 116;
/**
* Horizontal inset of the header chrome. `AppHeader` adds its own `px="lg"`
* inside this, so the nav strip below needs the sum to line up with the
* controls above it — it used to start 20px to their left.
*/
const CHROME_PAD_X = 32;
const NAV_STRIP_PAD_X = CHROME_PAD_X + 20;
/** /**
* A desk left unlocked with a license-review or medical-record screen open is * A desk left unlocked with a license-review or medical-record screen open is
* the actual threat model here, not a slow token. 15 minutes of no mouse, * the actual threat model here, not a slow token. 15 minutes of no mouse,
@@ -144,7 +152,9 @@ export function BackofficeLayout() {
return ( return (
<AppShell <AppShell
header={{ height: isSidebar ? 74 : HEADER_HEIGHT }} // The top layout drops its nav strip on small screens — the drawer is
// the nav there — so the header shrinks back to a single row with it.
header={{ height: isSidebar ? 74 : { base: 74, sm: HEADER_HEIGHT } }}
navbar={ navbar={
isSidebar isSidebar
? { ? {
@@ -162,13 +172,28 @@ export function BackofficeLayout() {
<AppShell.Header <AppShell.Header
style={{ style={{
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderBottom: "1px solid var(--mantine-color-gray-2)", borderBottom: "1px solid var(--mantine-color-default-border)",
display: "flex", display: "flex",
flexDirection: "column", flexDirection: "column",
}} }}
> >
<div style={{ height: 74, flexShrink: 0, padding: "0 32px" }}> <div
style={{ height: 74, flexShrink: 0, padding: `0 ${CHROME_PAD_X}px` }}
>
<AppHeader <AppHeader
brand={
isSidebar ? undefined : (
<Group gap="xs" wrap="nowrap">
<BrandMark size={28} />
<Text fw={700} size="sm" lh={1.1} visibleFrom="xs">
{t('app.name')}
</Text>
</Group>
)
}
// Nothing to toggle on a desktop top bar; on mobile it opens the
// drawer below.
burgerHiddenFrom={isSidebar ? undefined : 'sm'}
onToggleNav={toggleNav} onToggleNav={toggleNav}
onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav} onToggleSidebar={isSidebar ? handleToggleCollapse : toggleNav}
navOpened={opened} navOpened={opened}
@@ -182,13 +207,14 @@ export function BackofficeLayout() {
</div> </div>
{!isSidebar && ( {!isSidebar && (
<div <Box
visibleFrom="sm"
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
padding: '0 32px', padding: `0 ${NAV_STRIP_PAD_X}px`,
height: 42, height: 42,
borderTop: '1px solid var(--mantine-color-gray-1)', borderTop: '1px solid var(--mantine-color-default-border)',
flexShrink: 0, flexShrink: 0,
}} }}
> >
@@ -199,7 +225,7 @@ export function BackofficeLayout() {
activePath={location.pathname} activePath={location.pathname}
onNavigate={go} onNavigate={go}
/> />
</div> </Box>
)} )}
</AppShell.Header> </AppShell.Header>
@@ -210,7 +236,7 @@ export function BackofficeLayout() {
overflow: "hidden", overflow: "hidden",
transition: "width 200ms ease", transition: "width 200ms ease",
background: "var(--mantine-color-body)", background: "var(--mantine-color-body)",
borderRight: "1px solid var(--mantine-color-gray-2)", borderRight: "1px solid var(--mantine-color-default-border)",
}} }}
> >
<AppSidebar <AppSidebar
@@ -238,30 +264,28 @@ export function BackofficeLayout() {
{/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside {/* Mobile nav: a proper Drawer (sized, backdrop, closes on outside
click) instead of AppShell's full-width mobile navbar. Mirrors the click) instead of AppShell's full-width mobile navbar. Mirrors the
landing page's mobile menu. */} landing page's mobile menu. */}
{isSidebar && ( <Drawer
<Drawer opened={opened}
opened={opened} onClose={closeNav}
onClose={closeNav} hiddenFrom="sm"
hiddenFrom="sm" size="75%"
size="75%" padding={0}
padding={0} withCloseButton={false}
withCloseButton={false} >
> <AppSidebar
<AppSidebar navItems={sections}
navItems={sections} collapsed={false}
collapsed={false} activePath={location.pathname}
activePath={location.pathname} onToggleCollapse={handleToggleCollapse}
onToggleCollapse={handleToggleCollapse} onNavigate={(item) => {
onNavigate={(item) => { go(item);
go(item); closeNav();
closeNav(); }}
}} brandName={t('app.name')}
brandName={t('app.name')} brandSubtitle={t('app.authority')}
brandSubtitle={t('app.authority')} brandLogo={<BrandMark size={32} />}
brandLogo={<BrandMark size={32} />} />
/> </Drawer>
</Drawer>
)}
</AppShell> </AppShell>
); );
} }

View File

@@ -30,7 +30,12 @@ import {
IconShieldCheck, IconShieldCheck,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import { authStorage, useCurrentProfile } from '@ema-platform/auth'; import { authStorage, useCurrentProfile } from '@ema-platform/auth';
import { useApiQuery } from '@ema-platform/api'; import {
extractErrorMessage,
useApiQuery,
useBypassPaymentMutation,
useGetPaymentCapabilitiesQuery,
} from '@ema-platform/api';
import { import {
useGetMySeaServiceRecordsQuery, useGetMySeaServiceRecordsQuery,
useGetMyMedicalCertificatesQuery, useGetMyMedicalCertificatesQuery,
@@ -146,11 +151,32 @@ export function CertificatesPage() {
const [previewTitle, setPreviewTitle] = useState(''); const [previewTitle, setPreviewTitle] = useState('');
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const { pay, isPaying } = useApplicationPayment(); const { pay, isPaying } = useApplicationPayment();
// Dev/test only — the API reports false in production and the button is
// never rendered. Same shortcut My Applications offers.
const { data: capabilities } = useGetPaymentCapabilitiesQuery();
const [bypassPayment, { isLoading: bypassing }] = useBypassPaymentMutation();
const { data } = useApiQuery<CertificatesOverview>({ const { data, refetch } = useApiQuery<CertificatesOverview>({
url: '/certificates/my', url: '/certificates/my',
method: 'GET', method: 'GET',
}); });
const handleBypass = async (applicationId: string) => {
try {
const result = await bypassPayment(applicationId).unwrap();
notifications.show({
color: 'teal',
title: 'Payment bypassed',
message: result.certificateIssued
? 'The certificate has been issued.'
: `Application is now ${humanStatus(result.status)}.`,
});
// Generic query, not tag-driven: refresh it by hand.
refetch();
} catch (err) {
notifications.show({ color: 'red', title: 'Bypass failed', message: extractErrorMessage(err) });
}
};
const certificates = data?.certificates ?? []; const certificates = data?.certificates ?? [];
const applications = data?.applications ?? []; const applications = data?.applications ?? [];
@@ -331,6 +357,17 @@ export function CertificatesPage() {
Pay {app.feeAmount.toLocaleString()} {app.feeCurrency} Pay {app.feeAmount.toLocaleString()} {app.feeCurrency}
</Button> </Button>
)} )}
{app.feeAmount !== null && capabilities?.bypassEnabled && (
<Button
size="xs"
variant="default"
loading={bypassing}
onClick={() => handleBypass(app.applicationId)}
title="Testing only — marks the fee paid without a provider"
>
Bypass payment
</Button>
)}
<Text <Text
fz="xs" fz="xs"
c="blue" c="blue"

View File

@@ -164,6 +164,13 @@ function EvidenceField({
// ---------------------------------------------------------------- sea service // ---------------------------------------------------------------- sea service
/** Today as a `yyyy-mm-dd` key — same shape the pickers emit, so plain
* string comparison is a valid date comparison. Taken in the authority's
* timezone, matching the server's check, so a seafarer logging in from a
* zone ahead of Addis isn't offered a day the server then rejects. */
const todayKey = () =>
new Date().toLocaleDateString('en-CA', { timeZone: 'Africa/Addis_Ababa' });
const EMPTY_SEA_SERVICE = { const EMPTY_SEA_SERVICE = {
vesselName: '', vesselName: '',
imoNumber: '', imoNumber: '',
@@ -276,12 +283,27 @@ function SeaServiceTab() {
} }
}; };
// Service already served — neither end of an engagement can be in the future.
const today = todayKey();
const dateError =
form.engagementDate > today || form.dischargeDate > today
? t('seaRecords.seaService.dateFuture', {
defaultValue: 'Engagement and discharge dates cannot be in the future.',
})
: form.engagementDate &&
form.dischargeDate &&
form.engagementDate >= form.dischargeDate
? t('seaRecords.seaService.dateOrder', {
defaultValue: 'Discharge date must be after the engagement date.',
})
: null;
const valid = const valid =
form.vesselName.trim().length > 1 && form.vesselName.trim().length > 1 &&
form.rank.trim().length > 1 && form.rank.trim().length > 1 &&
form.engagementDate && form.engagementDate &&
form.dischargeDate && form.dischargeDate &&
form.engagementDate < form.dischargeDate; !dateError;
// Shown under the date pickers as they are filled: the seafarer sees what // Shown under the date pickers as they are filled: the seafarer sees what
// the engagement is worth before saving it. // the engagement is worth before saving it.
@@ -408,6 +430,7 @@ function SeaServiceTab() {
onChange={(val) => onChange={(val) =>
setForm({ ...form, engagementDate: val }) setForm({ ...form, engagementDate: val })
} }
maxDate={form.dischargeDate || today}
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
@@ -417,24 +440,23 @@ function SeaServiceTab() {
onChange={(val) => onChange={(val) =>
setForm({ ...form, dischargeDate: val }) setForm({ ...form, dischargeDate: val })
} }
minDate={form.engagementDate || undefined}
maxDate={today}
dateFormat="date" dateFormat="date"
/> />
</Group> </Group>
{form.engagementDate && form.dischargeDate && ( {(dateError || (form.engagementDate && form.dischargeDate)) && (
<Alert <Alert
variant="light" variant="light"
color={formDays === null ? 'red' : 'teal'} color={dateError ? 'red' : 'teal'}
icon={<IconInfoCircle size={16} />} icon={<IconInfoCircle size={16} />}
py={6} py={6}
> >
{formDays === null {dateError ??
? t('seaRecords.seaService.dateOrder', { t('seaRecords.seaService.daysServed', {
defaultValue: 'Discharge date must be after the engagement date.', days: formDays,
}) defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
: t('seaRecords.seaService.daysServed', { })}
days: formDays,
defaultValue: 'Days served on this engagement: {{days}} (both days counted)',
})}
</Alert> </Alert>
)} )}
<Textarea <Textarea
@@ -581,10 +603,12 @@ function MedicalTab() {
} }
}; };
const today = todayKey();
const valid = const valid =
form.issuerName.trim().length > 1 && form.issuerName.trim().length > 1 &&
form.issueDate && form.issueDate &&
form.expiryDate && form.expiryDate &&
form.issueDate <= today &&
form.issueDate < form.expiryDate; form.issueDate < form.expiryDate;
const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 }); const { setPageIndex, pageSize, setPageSize, paginate } = useServerTable({ pageSize: 10 });
@@ -664,6 +688,7 @@ function MedicalTab() {
required required
value={form.issueDate} value={form.issueDate}
onChange={(val) => setForm({ ...form, issueDate: val })} onChange={(val) => setForm({ ...form, issueDate: val })}
maxDate={today}
dateFormat="date" dateFormat="date"
/> />
<AmharicDatePicker <AmharicDatePicker
@@ -671,6 +696,7 @@ function MedicalTab() {
required required
value={form.expiryDate} value={form.expiryDate}
onChange={(val) => setForm({ ...form, expiryDate: val })} onChange={(val) => setForm({ ...form, expiryDate: val })}
minDate={form.issueDate || undefined}
dateFormat="date" dateFormat="date"
/> />
</Group> </Group>

View File

@@ -15,7 +15,7 @@ import {
import { TimeInput } from '@mantine/dates'; import { TimeInput } from '@mantine/dates';
import { useDisclosure } from '@mantine/hooks'; import { useDisclosure } from '@mantine/hooks';
import { DayPicker as EthiopicDayPicker } from '@daypicker/ethiopic'; 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 { IconCalendarEvent } from '@tabler/icons-react';
import '@daypicker/react/dist/style.css'; import '@daypicker/react/dist/style.css';
import './AmharicDatePicker.css'; import './AmharicDatePicker.css';
@@ -131,6 +131,11 @@ export interface AmharicDatePickerProps {
/** Show a time-of-day field alongside the calendar. Off by default — /** Show a time-of-day field alongside the calendar. Off by default —
* most callers only need a calendar day. */ * most callers only need a calendar day. */
withTime?: boolean; 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, /** Wire format for `value`/`onChange`: a full ISO-8601 instant (default,
* what most backend date fields expect) or a bare `yyyy-mm-dd` calendar * what most backend date fields expect) or a bare `yyyy-mm-dd` calendar
* date (what filter query params and plain `date: string` DTO fields * date (what filter query params and plain `date: string` DTO fields
@@ -151,6 +156,8 @@ export function AmharicDatePicker({
onBlur, onBlur,
w, w,
withTime = false, withTime = false,
minDate,
maxDate,
dateFormat = 'iso', dateFormat = 'iso',
}: AmharicDatePickerProps) { }: AmharicDatePickerProps) {
const { t, i18n } = useTranslation(); const { t, i18n } = useTranslation();
@@ -161,6 +168,21 @@ export function AmharicDatePicker({
const selected = parseWireValue(value, dateFormat, withTime); 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 const dateLabel = selected
? calendarType === 'EN' ? calendarType === 'EN'
? selected.toLocaleDateString('en-US', { ? selected.toLocaleDateString('en-US', {
@@ -266,6 +288,7 @@ export function AmharicDatePicker({
endMonth={YEAR_DROPDOWN_END} endMonth={YEAR_DROPDOWN_END}
numerals="latn" numerals="latn"
captionLayout="dropdown" captionLayout="dropdown"
disabled={outOfRange}
formatters={ETH_FORMATTERS} formatters={ETH_FORMATTERS}
onSelect={(date: Date | undefined) => { onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
@@ -281,6 +304,7 @@ export function AmharicDatePicker({
startMonth={YEAR_DROPDOWN_START} startMonth={YEAR_DROPDOWN_START}
endMonth={YEAR_DROPDOWN_END} endMonth={YEAR_DROPDOWN_END}
captionLayout="dropdown" captionLayout="dropdown"
disabled={outOfRange}
onSelect={(date: Date | undefined) => { onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : ''); onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
if (!withTime) close(); if (!withTime) close();
@@ -389,6 +413,7 @@ export function AmharicDatePicker({
<Button <Button
variant="light" variant="light"
size="xs" size="xs"
disabled={todayOutOfRange}
onClick={() => { onClick={() => {
onChange?.(formatWireValue(new Date(), dateFormat, withTime)); onChange?.(formatWireValue(new Date(), dateFormat, withTime));
close(); close();

View File

@@ -14,6 +14,7 @@ import {
IconLogout, IconLogout,
IconUserCircle, IconUserCircle,
} from '@tabler/icons-react'; } from '@tabler/icons-react';
import type { ReactNode } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { LanguageSwitcher } from './LanguageSwitcher'; import { LanguageSwitcher } from './LanguageSwitcher';
import { ColorSchemeToggle } from './ColorSchemeToggle'; import { ColorSchemeToggle } from './ColorSchemeToggle';
@@ -36,6 +37,17 @@ interface AppHeaderProps {
supportedLanguages: readonly string[]; supportedLanguages: readonly string[];
onNotificationsClick?: () => void; onNotificationsClick?: () => void;
notificationCount?: number; 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({ export function AppHeader({
@@ -50,17 +62,21 @@ export function AppHeader({
supportedLanguages, supportedLanguages,
onNotificationsClick, onNotificationsClick,
notificationCount, notificationCount,
brand,
burgerHiddenFrom,
}: AppHeaderProps) { }: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768; const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
return ( return (
<Group h="100%" px="lg" justify="space-between" wrap="nowrap"> <Group h="100%" px="lg" justify="space-between" wrap="nowrap">
<Group gap="md" wrap="nowrap"> <Group gap="md" wrap="nowrap">
{brand}
{/* Hamburger — styled like user-management Top.tsx */} {/* Hamburger — styled like user-management Top.tsx */}
{/* The Burger itself owns the click so the control is a real, keyboard {/* The Burger itself owns the click so the control is a real, keyboard
reachable <button>; the Box is chrome only. It previously wrapped a reachable <button>; the Box is chrome only. It previously wrapped a
no-op button, which no keyboard user could operate. */} no-op button, which no keyboard user could operate. */}
<Box <Box
hiddenFrom={burgerHiddenFrom}
style={{ style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',

View File

@@ -1,4 +1,5 @@
import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core'; import { Badge, Group, Menu, UnstyledButton, rem } from '@mantine/core';
import { forwardRef } from 'react';
import { IconChevronDown } from '@tabler/icons-react'; import { IconChevronDown } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { NavItem } from './AppSidebar'; 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 * 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 * grouping that the sidebar already had was thrown away. Here each section
* collapses to a single labelled dropdown, which fits and keeps the same * 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) { export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps) {
const { t } = useTranslation(); const { t } = useTranslation();
@@ -36,6 +39,7 @@ export function AppTopNav({ navItems, activePath, onNavigate }: AppTopNavProps)
wrap="nowrap" wrap="nowrap"
role="navigation" role="navigation"
aria-label={t('nav.primary', 'Primary')} aria-label={t('nav.primary', 'Primary')}
style={{ flex: 1, minWidth: 0, overflowX: 'auto', scrollbarWidth: 'none' }}
> >
{sections.map((section, index) => { {sections.map((section, index) => {
// An unlabelled leading block (Dashboard) is a plain link, not a menu. // An unlabelled leading block (Dashboard) is a plain link, not a menu.
@@ -148,40 +152,52 @@ interface TopNavButtonProps {
onClick?: () => void; onClick?: () => void;
} }
function TopNavButton({ /**
label, * `Menu.Target` positions its dropdown against the ref it passes to its child,
active, * so a plain function component here left every section menu anchored at the
badge, * top-left of the viewport, covering the header instead of opening under the
soon, * button that was clicked. The rest props carry Menu's own click and aria
withChevron, * handling onto the real button.
onClick, */
}: TopNavButtonProps) { const TopNavButton = forwardRef<HTMLButtonElement, TopNavButtonProps>(
return ( function TopNavButton(
<UnstyledButton { label, active, badge, soon, withChevron, onClick, ...others },
onClick={onClick} ref,
style={{ ) {
display: 'flex', return (
alignItems: 'center', <UnstyledButton
gap: rem(6), ref={ref}
padding: `0 ${rem(14)}`, onClick={onClick}
height: '100%', {...others}
borderBottom: '2px solid', style={{
borderBottomColor: active ? 'var(--mantine-color-blue-6)' : 'transparent', display: 'flex',
color: active ? 'var(--mantine-color-blue-6)' : 'var(--mantine-color-gray-6)', alignItems: 'center',
fontWeight: active ? 600 : 500, gap: rem(6),
fontSize: rem(14), padding: `0 ${rem(14)}`,
whiteSpace: 'nowrap', height: '100%',
opacity: soon ? 0.55 : 1, flexShrink: 0,
marginBottom: -1, borderBottom: '2px solid',
}} borderBottomColor: active
> ? 'var(--mantine-color-blue-6)'
<span>{label}</span> : 'transparent',
{badge !== null && badge !== undefined && ( color: active
<Badge size="xs" variant="filled" color="red" radius="sm"> ? 'var(--mantine-color-blue-6)'
{badge} : 'var(--mantine-color-gray-6)',
</Badge> fontWeight: active ? 600 : 500,
)} fontSize: rem(14),
{withChevron && <IconChevronDown size={14} stroke={2} />} whiteSpace: 'nowrap',
</UnstyledButton> 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>
);
},
);

38
package-lock.json generated
View File

@@ -31,6 +31,7 @@
"i18n-nationality": "^1.4.0", "i18n-nationality": "^1.4.0",
"i18next": "^25.6.0", "i18next": "^25.6.0",
"js-cookie": "^3.0.8", "js-cookie": "^3.0.8",
"libphonenumber-js": "^1.13.11",
"react": "^19.0.0", "react": "^19.0.0",
"react-dom": "^19.0.0", "react-dom": "^19.0.0",
"react-hook-form": "^7.71.2", "react-hook-form": "^7.71.2",
@@ -44,6 +45,8 @@
"zod": "^4.3.6" "zod": "^4.3.6"
}, },
"devDependencies": { "devDependencies": {
"@eslint/eslintrc": "3.3.6",
"@eslint/js": "^10.0.1",
"@nx/eslint": "^22.5.4", "@nx/eslint": "^22.5.4",
"@nx/eslint-plugin": "^22.5.4", "@nx/eslint-plugin": "^22.5.4",
"@nx/react": "^22.5.4", "@nx/react": "^22.5.4",
@@ -2799,16 +2802,24 @@
} }
}, },
"node_modules/@eslint/js": { "node_modules/@eslint/js": {
"version": "9.39.5", "version": "10.0.1",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz", "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz",
"integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==", "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^20.19.0 || ^22.13.0 || >=24"
}, },
"funding": { "funding": {
"url": "https://eslint.org/donate" "url": "https://eslint.org/donate"
},
"peerDependencies": {
"eslint": "^10.0.0"
},
"peerDependenciesMeta": {
"eslint": {
"optional": true
}
} }
}, },
"node_modules/@eslint/object-schema": { "node_modules/@eslint/object-schema": {
@@ -11484,6 +11495,19 @@
"url": "https://opencollective.com/eslint" "url": "https://opencollective.com/eslint"
} }
}, },
"node_modules/eslint/node_modules/@eslint/js": {
"version": "9.39.5",
"resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.39.5.tgz",
"integrity": "sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
"funding": {
"url": "https://eslint.org/donate"
}
},
"node_modules/eslint/node_modules/ajv": { "node_modules/eslint/node_modules/ajv": {
"version": "6.15.0", "version": "6.15.0",
"resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz",
@@ -13403,6 +13427,12 @@
"node": ">= 0.8.0" "node": ">= 0.8.0"
} }
}, },
"node_modules/libphonenumber-js": {
"version": "1.13.11",
"resolved": "https://registry.npmjs.org/libphonenumber-js/-/libphonenumber-js-1.13.11.tgz",
"integrity": "sha512-ETER2kMaIFTI/Nh1a8Gk03dUF/SL0VZqtI+CcVHZxp5WIHYwNS7S+uiYZDYCvLy3lOR4/DAD5jf0h5WkePPpqg==",
"license": "MIT"
},
"node_modules/lightningcss": { "node_modules/lightningcss": {
"version": "1.32.0", "version": "1.32.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz",

View File

@@ -1,3 +1,7 @@
packages:
- 'apps/*'
- 'libs/*'
allowBuilds: allowBuilds:
canvas: set this to true or false canvas: set this to true or false
core-js: set this to true or false core-js: set this to true or false

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Sync .env files from the Env Manager API into the repo.
#
# Usage:
# ENV_MANAGER_TOKEN=xxx ./scripts/deploy/sync-env-from-server.sh freight-api freight-portal freight-backoffice
#
# You normally only need to pass the token via the action secret, and BRANCH
# via the job-level env (e.g. `BRANCH: ${{ github.ref_name }}` in the workflow):
# env:
# BRANCH: ${{ github.ref_name }}
# ENV_MANAGER_TOKEN: ${{ secrets.ENV_MANAGER_TOKEN }}
#
# API layout (one endpoint per service):
# GET https://env.smart.aaca.gov.et/api/env/edr/<branch>/<service>?format=dotenv
# Header: Authorization: Bearer <ENV_MANAGER_TOKEN>
set -euo pipefail
PROJECT="ema"
ENV_MANAGER_URL="https://env.smart.aaca.gov.et"
BRANCH="${BRANCH:?BRANCH is required}"
ENV_MANAGER_TOKEN="${ENV_MANAGER_TOKEN:?ENV_MANAGER_TOKEN is required}"
declare -A SERVICE_ENV_TARGET=(
["ema_portal"]="apps/portal/.env"
["ema_backoffice"]="apps/backoffice/.env"
)
for service in "$@"; do
branch_api_name="${BRANCH//-/_}"
service_api_name="${service//-/_}"
dest="${SERVICE_ENV_TARGET[${service_api_name}]:-}"
if [[ -z "${dest}" ]]; then
echo "Unknown service: ${service}" >&2
exit 1
fi
url="${ENV_MANAGER_URL}/api/env/${PROJECT}/${branch_api_name}/${service_api_name}?format=dotenv"
mkdir -p "$(dirname "${dest}")"
tmp_file="$(mktemp)"
trap 'rm -f "${tmp_file}"' RETURN 2>/dev/null || true
http_status=$(curl -fsS -o "${tmp_file}" -w "%{http_code}" \
-H "Authorization: Bearer ${ENV_MANAGER_TOKEN}" \
"${url}") || {
echo "Failed to fetch env for '${service}' from ${url}" >&2
rm -f "${tmp_file}"
exit 1
}
if [[ "${http_status}" != "200" ]]; then
echo "Env Manager returned HTTP ${http_status} for '${service}' (${url})" >&2
rm -f "${tmp_file}"
exit 1
fi
if [[ ! -s "${tmp_file}" ]]; then
echo "Env Manager returned an empty response for '${service}' (${url})" >&2
rm -f "${tmp_file}"
exit 1
fi
mv "${tmp_file}" "${dest}"
echo "Synced ${url} -> ${dest}"
port_value=$(sed -n -E 's/^[[:space:]]*PORT[[:space:]]*=[[:space:]]*"?([^"#]+)"?[[:space:]]*(#.*)?$/\1/p' "${dest}" | head -n1 | tr -d '[:space:]')
if [[ -z "${port_value}" ]]; then
echo "Missing required PORT in env file for '${service}' (${dest})" >&2
exit 1
fi
if [[ -n "${GITHUB_ENV:-}" ]]; then
service_var=$(echo "${service}" | tr '[:lower:]-' '[:upper:]_')
echo "${service_var}_PORT=${port_value}" >> "${GITHUB_ENV}"
echo "Exported ${service_var}_PORT from ${dest}"
# Forward NEXT_PUBLIC_* and VITE_* vars so docker compose build can inject them as build args.
grep -E '^[[:space:]]*(NEXT_PUBLIC_|VITE_)[A-Za-z0-9_]+=' "${dest}" \
| sed -E 's/^[[:space:]]*//' >> "${GITHUB_ENV}" || true
fi
done