mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 13:02:50 +00:00
Merge branch 'WorkflowChange' of https://github.com/Tria-plc/emaui into feature/exam-attempt-domain
This commit is contained in:
5
.github/workflows/deploy.yml
vendored
5
.github/workflows/deploy.yml
vendored
@@ -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: |
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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
38
package-lock.json
generated
@@ -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",
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
80
scripts/deploy/sync-env-from-env-manager.sh
Normal file
80
scripts/deploy/sync-env-from-env-manager.sh
Normal 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
|
||||||
Reference in New Issue
Block a user