Merge branch 'dev' of github.com:Tria-plc/emaui into Refactor

Resolved by unifying on the lib/data AdvancedTable (PR #10) as the canonical
table component: kept its API plus teammate i18n/feature work, kept the
folder-per-table structure (index.tsx + columns.tsx + actions.tsx) across all
27 tables, removed the parallel lib/table implementation, and fixed
pre-existing compile breaks in ExamDetailPage/QuestionAssigner/RecordResultModal.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Nati
2026-08-13 11:31:27 +00:00
182 changed files with 14181 additions and 8141 deletions

View File

@@ -1,9 +1,9 @@
import { createApi } from '@reduxjs/toolkit/query/react';
import { baseQueryWithReauth } from './base-query-with-reauth';
import { createApi } from "@reduxjs/toolkit/query/react";
import { baseQueryWithReauth } from "./base-query-with-reauth";
import { tagTypes } from "./tagTypes";
export const baseApi = createApi({
reducerPath: 'baseApi',
reducerPath: "baseApi",
baseQuery: baseQueryWithReauth,
tagTypes: ['Api'],
tagTypes: ["Api", "backOfficeApi", "portalApi", ...tagTypes],
endpoints: () => ({}),
});

View File

@@ -0,0 +1 @@
export const tagTypes = ["ProfessionApi"];

View File

@@ -1,3 +1,4 @@
export * from './licensing.types';
export * from './licensing-api';
export * from './licensing.helpers';
export * from './use-localized';

View File

@@ -2,6 +2,7 @@ import { resolveTokenFromStorage } from '../../session';
import type {
Bilingual,
FormSectionConfig,
LicenseApplication,
LicenseStatus,
ValidationIssue,
} from './licensing.types';
@@ -129,10 +130,34 @@ export const TERMINAL_STATUSES: LicenseStatus[] = [
'REJECTED',
];
/** Licence types with no `companyName` — these are filed by a person, not a
* business, so display falls back to the applicant name captured in the form. */
export const APPLICANT_NAME_TYPE_KEYS = [
'SEAFARER_REGISTRATION',
'CERTIFICATE_OF_COMPETENCY',
'CERTIFICATE_OF_PROFICIENCY',
'VESSEL_REGISTRATION',
'VESSEL_OWNERSHIP_TRANSFER',
'ENDORSEMENT_COC',
'ENDORSEMENT_GOC',
];
/** Company name, or applicant name for licence types that have no company. */
export function applicantOrCompanyName(app: LicenseApplication): string | undefined {
if (!app.licenseType?.key || !APPLICANT_NAME_TYPE_KEYS.includes(app.licenseType.key)) {
return app.companyName ?? undefined;
}
const applicantName = (app.formData?.account as Record<string, unknown> | undefined)
?.applicantName;
return typeof applicantName === 'string' && applicantName ? applicantName : undefined;
}
/** Reads a bilingual value for the active language, falling back to English. */
export function localized(value: Bilingual | undefined, language = 'en'): string {
if (!value) return '';
return (language === 'am' ? value.am : value.en) ?? value.en ?? value.am ?? '';
// `||` not `??`: an empty Amharic string is "not translated", not a value —
// editors save `am: ''` freely (ExamPage, CertificationPage, LocationForm).
return (language === 'am' ? value.am : value.en) || value.en || value.am || '';
}
/**
@@ -223,6 +248,9 @@ export function buildWizardSteps(
* instead of showing an empty page.
*/
hasStaff?: boolean;
/** Active UI language. Components get this from `useLocalized`; this is a
* pure function, so the caller passes `i18n.language` through. */
language?: string;
},
): WizardStep[] {
const visible = [...sections]
@@ -237,7 +265,7 @@ export function buildWizardSteps(
if (!group) {
steps.push({
key: `section:${section.key}`,
label: localized(section.title),
label: localized(section.title, options?.language),
kind: 'sections',
sections: [section],
});
@@ -292,6 +320,7 @@ export type FieldErrors = Record<string, string>;
export function validateSections(
sections: FormSectionConfig[],
formData: Record<string, Record<string, unknown>>,
language = 'en',
): FieldErrors {
const errors: FieldErrors = {};
@@ -315,8 +344,8 @@ export function validateSections(
if (field.required && empty) {
errors[`${section.key}.${field.key}`] =
field.type === 'BOOLEAN'
? `${localized(field.label)} must be accepted`
: `${localized(field.label)} is required`;
? `${localized(field.label, language)} must be accepted`
: `${localized(field.label, language)} is required`;
continue;
}
if (empty) continue;

View File

@@ -7,35 +7,35 @@ export type Bilingual = { en?: string; am?: string };
* previously this lived in a backoffice mock page.
*/
export type LicenseStatus =
| 'DRAFT'
| 'SUBMITTED'
| 'UNDER_REVIEW'
| 'UNDER_EVALUATION'
| 'RESUBMIT_REQUIRED'
| 'INSPECTION_PENDING'
| 'INSPECTION_COMPLETED'
| 'APPROVED'
| 'REJECTED'
| 'ON_HOLD'
| 'PAYMENT_PENDING'
| 'PAID'
| 'PAYMENT_CONFIRMED'
| 'CERTIFICATE_ISSUED'
| 'COMPLETED';
| "DRAFT"
| "SUBMITTED"
| "UNDER_REVIEW"
| "UNDER_EVALUATION"
| "RESUBMIT_REQUIRED"
| "INSPECTION_PENDING"
| "INSPECTION_COMPLETED"
| "APPROVED"
| "REJECTED"
| "ON_HOLD"
| "PAYMENT_PENDING"
| "PAID"
| "PAYMENT_CONFIRMED"
| "CERTIFICATE_ISSUED"
| "COMPLETED";
export type ApplicationKind = 'NEW' | 'RENEWAL';
export type ApplicationKind = "NEW" | "RENEWAL";
export type FormFieldType =
| 'TEXT'
| 'TEXTAREA'
| 'NUMBER'
| 'MONEY'
| 'DATE'
| 'SELECT'
| 'BOOLEAN'
| 'EMAIL'
| 'PHONE'
| 'TIN';
| "TEXT"
| "TEXTAREA"
| "NUMBER"
| "MONEY"
| "DATE"
| "SELECT"
| "BOOLEAN"
| "EMAIL"
| "PHONE"
| "TIN";
export interface FieldCondition {
field: string;
@@ -138,7 +138,7 @@ export interface DocumentRequirement {
name: Bilingual;
description?: Bilingual;
applicationKind: ApplicationKind;
mode: 'ALWAYS' | 'CONDITIONAL' | 'OPTIONAL';
mode: "ALWAYS" | "CONDITIONAL" | "OPTIONAL";
conditionExpression?: FieldCondition & { previousDocExpired?: string };
allowedMimeTypes: string[];
maxSizeMb: number;
@@ -245,7 +245,7 @@ export interface StatusHistoryEntry {
createdAt: string;
}
export type RemarkTargetType = 'FORM_SECTION' | 'DOCUMENT' | 'STAFF';
export type RemarkTargetType = "FORM_SECTION" | "DOCUMENT" | "STAFF";
export interface ApplicationRemark {
id: string;
@@ -276,8 +276,8 @@ export interface Inspection {
scheduledDate: string | null;
conductedDate: string | null;
location: string | null;
status: 'SCHEDULED' | 'COMPLETED' | 'CANCELLED';
result: 'PASSED' | 'FAILED' | null;
status: "SCHEDULED" | "COMPLETED" | "CANCELLED";
result: "PASSED" | "FAILED" | null;
findings: string | null;
}
@@ -303,17 +303,18 @@ export interface QueueFilter {
submittedTo?: string;
overdue?: boolean;
sortBy?: QueueSortField;
sortDir?: 'ASC' | 'DESC';
sortDir?: "ASC" | "DESC";
take?: number;
skip?: number;
}
export type QueueSortField =
| 'submittedAt'
| 'applicationNumber'
| 'companyName'
| 'status'
| 'dueAt';
| "submittedAt"
| "applicationNumber"
| "companyName"
| "status"
| "dueAt"
| "claimedAt";
/** Row counts behind the queue's saved-view tabs. */
export interface QueueCounts {
@@ -325,7 +326,7 @@ export interface QueueCounts {
all: number;
}
export type DocumentDecision = 'ACCEPTED' | 'REJECTED';
export type DocumentDecision = "ACCEPTED" | "REJECTED";
/** An officer's verdict on one uploaded document. */
export interface DocumentReview {
@@ -363,10 +364,10 @@ export interface ExportResult {
truncated: boolean;
}
export type TemplateStatus = 'DRAFT' | 'PUBLISHED' | 'ARCHIVED';
export type TemplateStatus = "DRAFT" | "PUBLISHED" | "ARCHIVED";
export interface TemplatePageOptions {
format?: 'A4' | 'A5' | 'Letter' | 'Legal';
format?: "A4" | "A5" | "Letter" | "Legal";
landscape?: boolean;
printBackground?: boolean;
}
@@ -398,7 +399,7 @@ export interface Paginated<T> {
/** Per-field problems returned by the server when a submission is incomplete. */
export interface ValidationIssue {
kind: 'field' | 'document' | 'staff';
kind: "field" | "document" | "staff";
target: string;
field?: string;
message: string;
@@ -406,7 +407,7 @@ export interface ValidationIssue {
/** What the browser must do to complete a payment. */
export interface ClientAction {
type: 'REDIRECT' | 'LAUNCH_APP' | 'NONE';
type: "REDIRECT" | "LAUNCH_APP" | "NONE";
url?: string;
appId?: string;
receiveCode?: string;
@@ -414,12 +415,7 @@ export interface ClientAction {
}
export type PaymentStatus =
| 'PENDING'
| 'PROCESSING'
| 'PAID'
| 'FAILED'
| 'EXPIRED'
| 'CANCELLED';
"PENDING" | "PROCESSING" | "PAID" | "FAILED" | "EXPIRED" | "CANCELLED";
export interface InitiatePaymentResult {
paymentId: string;
@@ -461,7 +457,7 @@ export interface IssuedLicense {
tinNumber: string | null;
issueDate: string;
expiryDate: string;
status: 'ACTIVE' | 'EXPIRED' | 'SUSPENDED' | 'CANCELLED' | 'SUPERSEDED';
status: "ACTIVE" | "EXPIRED" | "SUSPENDED" | "CANCELLED" | "SUPERSEDED";
/**
* Days until the expiry date; negative once it has passed. Computed by the
* API in the authority's timezone — the client must not re-derive it, since

View File

@@ -0,0 +1,27 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { localized } from './licensing.helpers';
import type { Bilingual } from './licensing.types';
/**
* The component-facing bilingual reader — the twin of `useDateDisplayer()`.
*
* A hook rather than a bare `localized` import so the calling component is
* subscribed to i18next: switching language re-renders it and every
* backend-configured label flips with the rest of the UI. `useTranslation()`
* with no instance argument resolves to the app's own <I18nextProvider>
* (portal router.tsx, backoffice AppProviders.tsx), which is what makes this
* work across two separate i18n instances.
*
* DISPLAY ONLY. Code that *matches* on a label — `.includes('nationality')`,
* the vessel-picker regex in ConfigDrivenSection, the SUBCITY/WOREDA test in
* AddressFormContent — must keep reading `value.en`, or the match breaks the
* moment the user switches language.
*
* The returned function is stable per language, so it is safe — and required —
* as a useMemo/useCallback dependency.
*/
export function useLocalized(): (value: Bilingual | undefined) => string {
const { i18n } = useTranslation();
return useCallback((value) => localized(value, i18n.language), [i18n.language]);
}

View File

@@ -0,0 +1,105 @@
import { useCallback, useState } from "react";
import { baseApi } from "../base-api";
import { resolveTokenFromStorage } from "../session";
export interface UploadFileInfo {
bucket: string;
fileName: string;
contentType: string;
originalname: string;
size: number;
}
export interface UploadKeyResponse {
fileInfo: UploadFileInfo;
presigned: string;
}
const fileUploadApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getFileUploadKey: builder.mutation<
UploadKeyResponse,
{ endpoint: string; file: File }
>({
query: ({ endpoint, file }) => ({
url: endpoint,
method: "POST",
body: {
fileName: file.name,
contentType: file.type || "application/octet-stream",
size: file.size,
originalname: file.name,
},
}),
}),
}),
overrideExisting: false,
});
export const { useGetFileUploadKeyMutation } = fileUploadApi;
export const STORAGE_UPLOAD_ERROR = "STORAGE_UPLOAD_ERROR";
export const STORAGE_UPLOAD_TOO_LARGE = "STORAGE_UPLOAD_TOO_LARGE";
export function isStorageUploadError(error: unknown): error is Error & {
message: typeof STORAGE_UPLOAD_ERROR | typeof STORAGE_UPLOAD_TOO_LARGE;
} {
return (
error instanceof Error &&
(error.message === STORAGE_UPLOAD_ERROR ||
error.message === STORAGE_UPLOAD_TOO_LARGE)
);
}
// Bare fetch on purpose: this PUT targets the presigned storage URL directly, not
// VITE_BASE_API_URL, so it must not go through baseApi/fetchBaseQuery.
async function uploadToPresigned(
file: File,
presignedUrl: string,
): Promise<void> {
if (!presignedUrl) throw new Error(STORAGE_UPLOAD_ERROR);
const token = resolveTokenFromStorage();
const res = await fetch(presignedUrl, {
method: "PUT",
body: file,
headers: {
"Content-Type": file.type || "application/octet-stream",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
});
if (res.status === 413) throw new Error(STORAGE_UPLOAD_TOO_LARGE);
if (!res.ok) throw new Error(STORAGE_UPLOAD_ERROR);
}
export function useDocumentUpload() {
const [getFileUploadKey, mutationState] = useGetFileUploadKeyMutation();
// Spans both steps (key request + presigned PUT) — mutationState.isLoading alone
// would drop to false once the key request resolves, before the file PUT finishes.
const [isUploading, setIsUploading] = useState(false);
const upload = useCallback(
async (file: File, endpoint = "/documents/get-file-upload-key"): Promise<UploadKeyResponse> => {
setIsUploading(true);
try {
const { presigned, fileInfo } = await getFileUploadKey({
endpoint,
file,
}).unwrap();
await uploadToPresigned(file, presigned);
return { presigned, fileInfo };
} finally {
setIsUploading(false);
}
},
[getFileUploadKey],
);
return {
upload,
isUploading,
error: mutationState.error,
reset: mutationState.reset,
};
}

View File

@@ -1,3 +1,5 @@
import Cookies from 'js-cookie';
export const SESSION_HEADER_KEYS = {
tenantId: 'x-tenant-id',
organizationUnitId: 'x-organization-unit-id',
@@ -12,12 +14,14 @@ const TOKEN_STORAGE_KEYS = [
] as const;
export function resolveTokenFromStorage(): string | undefined {
// cookie first, then localStorage (legacy pre-migration sessions)
for (const key of TOKEN_STORAGE_KEYS) {
const cookie = Cookies.get(key);
if (cookie) return cookie;
const stored = localStorage.getItem(key);
if (stored) return stored;
}
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
return undefined;
}
export function resolveSessionContext(state?: { auth?: { token?: string } }): {

View File

@@ -1,17 +1,31 @@
export { AuthConfigProvider, useAuthConfig } from './lib/AuthConfig';
export type { AuthConfigValue } from './lib/AuthConfig';
export { AuthShell, BrandMark } from './lib/components/AuthShell';
export { ProtectedRoute } from './lib/components/ProtectedRoute';
export { AuthBootstrap } from './lib/components/AuthBootstrap';
export { LoginPage } from './lib/pages/LoginPage';
export { SignupPage } from './lib/pages/SignupPage';
export { ForgotPasswordPage } from './lib/pages/ForgotPasswordPage';
export { SetPasswordPage } from './lib/pages/SetPasswordPage';
export { OTPVerificationPage } from './lib/pages/OTPVerificationPage';
export { authReducer, loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } from './lib/store/auth.slice';
export { signupReducer, setSignupData, setSignupStep, resetSignup } from './lib/store/signup.slice';
export { usePermissions } from './lib/hooks/usePermissions';
export type { PermissionSet } from './lib/hooks/usePermissions';
export { AuthConfigProvider, useAuthConfig } from "./lib/AuthConfig";
export type { AuthConfigValue } from "./lib/AuthConfig";
export { AuthShell, BrandMark } from "./lib/components/AuthShell";
export { ProtectedRoute } from "./lib/components/ProtectedRoute";
export { AuthBootstrap } from "./lib/components/AuthBootstrap";
export { LoginPage } from "./lib/pages/LoginPage";
export { SignupPage } from "./lib/pages/SignupPage";
export { ForgotPasswordPage } from "./lib/pages/ForgotPasswordPage";
export { SetPasswordPage } from "./lib/pages/SetPasswordPage";
export { OTPVerificationPage } from "./lib/pages/OTPVerificationPage";
export {
authReducer,
loginSuccess,
setUser,
setCurrentProfile,
clearCurrentProfile,
logout,
hydrateAuth,
setToken,
} from "./lib/store/auth.slice";
export {
signupReducer,
setSignupData,
setSignupStep,
resetSignup,
} from "./lib/store/signup.slice";
export { usePermissions } from "./lib/hooks/usePermissions";
export type { PermissionSet } from "./lib/hooks/usePermissions";
export {
useCurrentProfile,
useGetMyProfileQuery,
@@ -19,8 +33,19 @@ export {
useUpdateMyAddressMutation,
PROFILE_FIELDS,
PROFILE_FIELD_SECTION,
} from './lib/hooks/useCurrentProfile';
export type { ProfileField, ProfileRequirement, ProfileMeResponse } from './lib/hooks/useCurrentProfile';
export { configureAuthStorage, authStorage } from './lib/utils/auth-storage';
export { refreshAccessToken } from './lib/utils/refresh-token';
export type { AuthUser, AuthState, LoginPayload, CurrentProfile, CurrentProfileAddress, CurrentProfileProfession } from './lib/types/auth.types';
} from "./lib/hooks/useCurrentProfile";
export type {
ProfileField,
ProfileRequirement,
ProfileMeResponse,
} from "./lib/hooks/useCurrentProfile";
export { configureAuthStorage, authStorage } from "./lib/utils/auth-storage";
export { refreshAccessToken } from "./lib/utils/refresh-token";
export type {
AuthUser,
AuthState,
LoginPayload,
CurrentProfile,
CurrentProfileAddress,
CurrentProfileProfession,
} from "./lib/types/auth.types";

View File

@@ -30,6 +30,7 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
dispatch(hydrateAuth());
const token = authStorage.getToken();
const cachedUser = authStorage.getUser<AuthUser>();
if (!token) {
if (!cancelled) setReady(true);
return;
@@ -42,7 +43,12 @@ export function AuthBootstrap({ children }: { children: ReactNode }) {
if (response.ok) {
const user = (await response.json()) as AuthUser;
if (!cancelled) dispatch(setUser(user));
// Keep the persisted session as the source of truth when it is
// available. A successful profile update writes it immediately,
// while `/auth/me` can briefly return a stale read and otherwise
// undo that update on every page refresh. We still make this call
// to validate the token and clear invalid sessions below.
if (!cancelled && !cachedUser) dispatch(setUser(user));
} else if (response.status === 401 || response.status === 403) {
// Expired or revoked — drop it so the user gets a login screen
// instead of a dead end.

View File

@@ -1,3 +1,4 @@
import Cookies from 'js-cookie';
import { Navigate, Outlet, useLocation } from 'react-router-dom';
import type { ReactNode } from 'react';
import { authStorage } from '../utils/auth-storage';
@@ -7,14 +8,9 @@ interface ProtectedRouteProps {
loginPath?: string;
}
function getTokenFromCookie(): string | undefined {
const match = document.cookie.match(/(?:^|;\s*)auth-token=([^;]*)/);
return match ? decodeURIComponent(match[1]) : undefined;
}
export function ProtectedRoute({ children, loginPath = '/login' }: ProtectedRouteProps) {
const location = useLocation();
const token = authStorage.getToken() ?? getTokenFromCookie();
const token = authStorage.getToken() ?? Cookies.get('auth-token');
if (!token) {
return <Navigate to={loginPath} state={{ from: location }} replace />;

View File

@@ -38,21 +38,32 @@ export const PROFILE_FIELDS = [
export type ProfileField = (typeof PROFILE_FIELDS)[number];
/** Which `/profile` tab collects a field — used to build deep links. */
export const PROFILE_FIELD_SECTION: Record<ProfileField, 'personal' | 'address' | 'emergency'> = {
firstName: 'personal',
middleName: 'personal',
lastName: 'personal',
gender: 'personal',
dob: 'personal',
pob: 'personal',
maritalStatus: 'personal',
professionId: 'personal',
/**
* Which `/profile` tab collects a field — used to build deep links.
*
* `personal` here means the Personal tab (account details: name, username,
* email, phone), which is `/profile`'s `'personal'` panel. Name/DOB/gender/
* profession live on the *Maritime Profile* panel instead, whose tab key is
* `'profile'` — easy to conflate with the field-section name of the same
* word, so it's called out here rather than left implicit.
*/
export const PROFILE_FIELD_SECTION: Record<
ProfileField,
'personal' | 'profile' | 'address' | 'emergency'
> = {
firstName: 'profile',
middleName: 'profile',
lastName: 'profile',
gender: 'profile',
dob: 'profile',
pob: 'profile',
maritalStatus: 'profile',
professionId: 'profile',
idType: 'address',
idNumber: 'address',
nationality: 'address',
primaryPhoneNumber: 'address',
email: 'address',
primaryPhoneNumber: 'personal',
email: 'personal',
regionId: 'address',
cityId: 'address',
subCityId: 'address',

View File

@@ -22,7 +22,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { Link } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
@@ -37,6 +37,7 @@ export function ForgotPasswordPage() {
const [forgotTrigger, { isLoading }] = useApiMutation();
const [sentTo, setSentTo] = useState<string | null>(null);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
const {
register,
@@ -59,11 +60,7 @@ export function ForgotPasswordPage() {
try {
await sendResetLink(values.email);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
setServerError(handleError(err));
}
};
@@ -73,11 +70,7 @@ export function ForgotPasswordPage() {
await sendResetLink(sentTo);
notify.success('Reset link sent again');
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
setServerError(handleError(err));
}
};

View File

@@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState } from "react";
import {
Alert,
Anchor,
@@ -11,29 +11,56 @@ import {
Text,
TextInput,
Title,
} from '@mantine/core';
} from "@mantine/core";
import {
IconArrowRight,
IconDeviceMobile,
IconLock,
IconMail,
} from '@tabler/icons-react';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser, setCurrentProfile } from '../store/auth.slice';
import type { LoginPayload, AuthUser, CurrentProfile } from '../types/auth.types';
import { useAuthConfig } from '../AuthConfig';
import { authStorage } from '../utils/auth-storage';
} from "@tabler/icons-react";
import { useForm } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import { z } from "zod";
import { useNavigate, Link } from "react-router-dom";
import { useDispatch } from "react-redux";
import { useApiMutation } from "@ema-platform/api";
import { notify, useErrorHandler } from "@ema-platform/ui";
import { AuthShell } from "../components/AuthShell";
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
import type {
LoginPayload,
AuthUser,
CurrentProfile,
} from "../types/auth.types";
import { useAuthConfig } from "../AuthConfig";
import { authStorage } from "../utils/auth-storage";
const emailOrPhone = z
.string()
.trim()
.transform((value) => {
// Convert 09xxxxxxxx -> +2519xxxxxxxx
if (/^09\d{8}$/.test(value)) {
return `+251${value.substring(1)}`;
}
return value;
})
.refine(
(value) => {
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
const phoneRegex = /^\+2519\d{8}$/;
return emailRegex.test(value) || phoneRegex.test(value);
},
{
message: "Enter a valid email or phone number (+2519xxxxxxxx)",
},
);
const schema = z.object({
email: z.string().email({ message: 'Enter a valid email' }),
password: z.string().min(5, { message: 'Password must be at least 6 characters' }),
email: emailOrPhone,
password: z
.string()
.min(8, { message: "Password must be at least 8 characters" }),
});
type FormValues = z.infer<typeof schema>;
@@ -46,9 +73,7 @@ export function LoginPage() {
const [isLoading, setIsLoading] = useState(false);
const [rememberMe, setRememberMe] = useState(true);
const [serverError, setServerError] = useState<string | null>(null);
// MFA second step: set once /auth/login answers `mfaRequired` (US-IAM-006).
const [mfaEmail, setMfaEmail] = useState<string | null>(null);
const [mfaOtp, setMfaOtp] = useState('');
const { handleError } = useErrorHandler();
const [loginTrigger] = useApiMutation<LoginPayload>();
const [meTrigger] = useApiMutation<AuthUser>();
const [profileTrigger] = useApiMutation<{ profile: CurrentProfile | null }>();
@@ -65,57 +90,15 @@ export function LoginPage() {
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/login',
method: 'POST',
url: "/auth/login",
method: "POST",
body: values,
}).unwrap();
// MFA-enabled accounts get no tokens yet — an OTP has been sent, and
// the session only exists once /auth/mfa-verify accepts it (US-IAM-006).
if (data.mfaRequired) {
setMfaEmail(values.email);
notify.success('Enter the verification code we just sent you');
return;
}
await completeSession(data);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
} finally {
setIsLoading(false);
}
};
const verifyMfa = async () => {
if (!mfaEmail || !mfaOtp.trim()) return;
setIsLoading(true);
try {
const data = await loginTrigger({
url: '/auth/mfa-verify',
method: 'POST',
body: { email: mfaEmail, otp: mfaOtp.trim() },
}).unwrap();
// The second factor proves possession of the verified phone.
await completeSession({ ...data, isPhoneNumberVerified: true });
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
'Verification failed';
setServerError(msg === 'unable_to_log_in' ? 'Invalid or expired code' : msg);
} finally {
setIsLoading(false);
}
};
const completeSession = async (data: LoginPayload) => {
dispatch(loginSuccess(data));
dispatch(loginSuccess(data));
const me = await meTrigger({
url: '/auth/me',
method: 'GET',
url: "/auth/me",
method: "GET",
}).unwrap();
dispatch(setUser(me));
@@ -126,8 +109,8 @@ export function LoginPage() {
// `useCurrentProfile` resolves it again on demand.
try {
const { profile } = await profileTrigger({
url: '/profiles/me',
method: 'GET',
url: "/profiles/me",
method: "GET",
}).unwrap();
if (profile) {
authStorage.setProfileId(profile.id);
@@ -137,17 +120,23 @@ export function LoginPage() {
// Offline or a 5xx — the portal still works; the resolver retries.
}
if (!me.isPhoneNumberVerified) {
navigate('/otp-verify', {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
},
});
return;
}
if (!me.isPhoneNumberVerified) {
navigate("/otp-verify", {
state: {
email: me.email,
phoneNumber: me.phoneNumber,
},
});
return;
}
navigate(loginRedirectPath);
navigate(loginRedirectPath);
} catch (err: unknown) {
setServerError(handleError(err));
} finally {
localStorage.setItem("rememberMe", String(rememberMe));
setIsLoading(false);
}
};
return (
@@ -163,47 +152,16 @@ export function LoginPage() {
</div>
{serverError && (
<Alert variant="light" color="red" withCloseButton onClose={() => setServerError(null)}>
<Alert
variant="light"
color="red"
withCloseButton
onClose={() => setServerError(null)}
>
{serverError}
</Alert>
)}
{mfaEmail ? (
<Stack gap="md">
<Alert variant="light" color="blue" icon={<IconDeviceMobile size={18} />}>
This account requires a second factor. Enter the code we sent to
your registered phone.
</Alert>
<TextInput
label="Verification code"
placeholder="6-digit code"
size="md"
value={mfaOtp}
onChange={(e) => setMfaOtp(e.currentTarget.value)}
onKeyDown={(e) => e.key === 'Enter' && verifyMfa()}
/>
<Button
size="md"
loading={isLoading}
disabled={!mfaOtp.trim()}
onClick={verifyMfa}
rightSection={<IconArrowRight size={18} />}
>
Verify and sign in
</Button>
<Anchor
size="sm"
ta="center"
onClick={() => {
setMfaEmail(null);
setMfaOtp('');
setServerError(null);
}}
>
Back to sign in
</Anchor>
</Stack>
) : (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
@@ -212,7 +170,7 @@ export function LoginPage() {
size="md"
leftSection={<IconMail size={18} />}
error={errors.email?.message}
{...register('email')}
{...register("email")}
/>
<PasswordInput
label="Password"
@@ -220,7 +178,7 @@ export function LoginPage() {
size="md"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
{...register("password")}
/>
<Group justify="space-between">
@@ -253,23 +211,9 @@ export function LoginPage() {
</Button>
</Stack>
</form>
)}
<Divider label="or" labelPosition="center" />
<Button
variant="default"
fullWidth
size="md"
leftSection={<IconDeviceMobile size={18} />}
onClick={() => notify.info('Phone sign-in is coming soon.')}
>
Sign in with phone number
</Button>
{enableSignup && (
<Text ta="center" size="sm" c="dimmed">
Don&apos;t have an account?{' '}
Don&apos;t have an account?{" "}
<Anchor component={Link} to="/signup" fw={700}>
Create one
</Anchor>

View File

@@ -18,7 +18,7 @@ import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate, useLocation } from 'react-router-dom';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { notify, useErrorHandler } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { useAuthConfig } from '../AuthConfig';
@@ -47,6 +47,7 @@ export function OTPVerificationPage() {
const [resendTrigger, { isLoading: resending }] = useApiMutation();
const [secondsLeft, setSecondsLeft] = useState(RESEND_SECONDS);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
const {
control,
@@ -74,11 +75,7 @@ export function OTPVerificationPage() {
notify.success('Phone number verified successfully');
navigate(loginRedirectPath);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
setServerError(handleError(err));
}
};
@@ -95,11 +92,7 @@ export function OTPVerificationPage() {
setSecondsLeft(RESEND_SECONDS);
setServerError(null);
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
setServerError(handleError(err));
}
};

View File

@@ -26,7 +26,7 @@ import { z } from 'zod';
import { useNavigate, Link } from 'react-router-dom';
import { useDispatch } from 'react-redux';
import { useApiMutation } from '@ema-platform/api';
import { notify } from '@ema-platform/ui';
import { useErrorHandler, passwordSchema, PasswordRequirements } from '@ema-platform/ui';
import { AuthShell } from '../components/AuthShell';
import { loginSuccess, setUser } from '../store/auth.slice';
import type { AuthUser } from '../types/auth.types';
@@ -40,8 +40,8 @@ const schema = z
userType: z.literal('individual'),
nameEn: z.string().min(1, { message: 'Name (English) is required' }),
nameAm: z.string().optional(),
password: z.string().min(8, { message: 'Password must be at least 8 characters' }),
confirmPassword: z.string().min(8, { message: 'Confirm your password' }),
password: passwordSchema(8),
confirmPassword: z.string().min(1, { message: 'Confirm your password' }),
})
.refine((data) => data.password === data.confirmPassword, {
message: 'Passwords do not match',
@@ -69,6 +69,7 @@ export function SignupPage() {
const { appName, loginRedirectPath } = useAuthConfig();
const [agreed, setAgreed] = useState(false);
const [serverError, setServerError] = useState<string | null>(null);
const { handleError } = useErrorHandler();
const [signupTrigger, { isLoading: loading }] = useApiMutation<{
token: string;
refreshToken: string;
@@ -79,6 +80,7 @@ export function SignupPage() {
const {
register,
handleSubmit,
watch,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
@@ -125,11 +127,7 @@ export function SignupPage() {
});
}
} catch (err: unknown) {
const msg =
(err as { data?: { message?: string } })?.data?.message ??
(err instanceof Error ? err.message : 'Something went wrong');
setServerError(msg);
notify.error(msg);
setServerError(handleError(err));
}
};
@@ -199,13 +197,16 @@ export function SignupPage() {
/>
<SimpleGrid cols={{ base: 1, xs: 2 }} spacing="md">
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<div>
<PasswordInput
label="Password"
placeholder="At least 8 characters"
leftSection={<IconLock size={18} />}
error={errors.password?.message}
{...register('password')}
/>
<PasswordRequirements password={watch('password') ?? ''} minLength={8} />
</div>
<PasswordInput
label="Confirm password"
placeholder="Re-enter password"

View File

@@ -1,6 +1,11 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, AuthUser, CurrentProfile, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
import type {
AuthState,
AuthUser,
CurrentProfile,
LoginPayload,
} from "../types/auth.types";
import { authStorage } from "../utils/auth-storage";
const initialState: AuthState = {
user: null,
@@ -10,7 +15,7 @@ const initialState: AuthState = {
};
const authSlice = createSlice({
name: 'auth',
name: "auth",
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
@@ -38,6 +43,10 @@ const authSlice = createSlice({
state.currentProfile = null;
authStorage.clear();
},
setToken(state, action: PayloadAction<string>) {
state.token = action.payload;
authStorage.setToken(action.payload);
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser<AuthUser>();
@@ -54,5 +63,13 @@ const authSlice = createSlice({
},
});
export const { loginSuccess, setUser, setCurrentProfile, clearCurrentProfile, logout, hydrateAuth } = authSlice.actions;
export const {
loginSuccess,
setUser,
setCurrentProfile,
clearCurrentProfile,
logout,
hydrateAuth,
setToken,
} = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -13,6 +13,7 @@ export interface AuthUser {
hasFinishedRegistration: boolean;
hasFinishedDMSOnboarding: boolean;
isPhoneNumberVerified: boolean;
userType: string;
}
export interface AuthState {

View File

@@ -1,7 +1,119 @@
let _prefix = 'ema-auth';
import Cookies from "js-cookie";
export function configureAuthStorage(prefix: string) {
function getCookieExpiresDays(): number {
if (typeof window === "undefined") return 1;
try {
const rememberMe = localStorage.getItem("rememberMe") === "true";
return rememberMe ? 15 : 1;
} catch {
return 1;
}
}
const isHttps =
typeof window !== "undefined" && window.location.protocol === "https:";
function removeCookieCompletely(name: string) {
if (typeof document === "undefined") return;
const paths = ["/", ""];
const samesites: Array<"strict" | "lax" | "none" | undefined> = [
"strict",
"lax",
undefined,
];
const secures = [true, false];
for (const path of paths) {
for (const secure of secures) {
for (const sameSite of samesites) {
try {
Cookies.remove(name, { path, secure, sameSite });
} catch {
// ignore
}
}
}
}
const pastDate = "Thu, 01 Jan 1970 00:00:00 GMT";
const expireStrings = [
`${name}=; path=/; expires=${pastDate}; max-age=0`,
`${name}=; path=/; expires=${pastDate}; max-age=0; SameSite=Strict`,
`${name}=; path=/; expires=${pastDate}; max-age=0; SameSite=Strict; Secure`,
`${name}=; path=/; expires=${pastDate}; max-age=0; SameSite=Lax`,
`${name}=; path=/; expires=${pastDate}; max-age=0; SameSite=Lax; Secure`,
`${name}=; expires=${pastDate}; max-age=0`,
];
try {
const domain = window.location.hostname;
const domainParts = domain.split(".");
if (domainParts.length > 1) {
expireStrings.push(
`${name}=; path=/; domain=${domain}; expires=${pastDate}; max-age=0`
);
expireStrings.push(
`${name}=; path=/; domain=.${domain}; expires=${pastDate}; max-age=0`
);
}
} catch {
// ignore
}
expireStrings.forEach((str) => {
try {
document.cookie = str;
} catch {
// ignore
}
});
}
interface Backend {
get(k: string): string | null;
set(k: string, v: string): void;
remove(k: string): void;
}
const localStore: Backend = {
get: (k) => localStorage.getItem(k),
set: (k, v) => localStorage.setItem(k, v),
remove: (k) => localStorage.removeItem(k),
};
const cookieStore: Backend = {
get: (k) => Cookies.get(k) ?? null,
set: (k, v) =>
Cookies.set(k, v, {
path: "/",
secure: isHttps,
sameSite: "strict",
expires: getCookieExpiresDays(),
}),
remove: (k) => removeCookieCompletely(k),
};
const AUTH_KEY_NAMES = [
"auth-token",
"refresh-token",
"auth-user",
"profile-id",
"current-profile",
];
const KNOWN_PREFIXES = ["ema-backoffice", "ema-portal", "ema-auth", ""];
let _prefix = "ema-auth";
let backend: Backend = localStore;
export function configureAuthStorage(prefix: string, useCookies = false) {
_prefix = prefix;
backend = useCookies ? cookieStore : localStore;
if (useCookies) {
// drop pre-migration localStorage leftovers so tokens live in cookies only
AUTH_KEY_NAMES.forEach((k) => localStorage.removeItem(`${prefix}-${k}`));
}
}
function key(k: string) {
@@ -9,34 +121,63 @@ function key(k: string) {
}
export const authStorage = {
getToken: () => localStorage.getItem(key('auth-token')) ?? undefined,
setToken: (token: string) => localStorage.setItem(key('auth-token'), token),
getRefreshToken: () => localStorage.getItem(key('refresh-token')) ?? undefined,
setRefreshToken: (t: string) => localStorage.setItem(key('refresh-token'), t),
getToken: () => backend.get(key("auth-token")) ?? undefined,
setToken: (token: string) => backend.set(key("auth-token"), token),
getRefreshToken: () => backend.get(key("refresh-token")) ?? undefined,
setRefreshToken: (t: string) => backend.set(key("refresh-token"), t),
getUser: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('auth-user')) ?? 'null') as T | null;
return JSON.parse(backend.get(key("auth-user")) ?? "null") as T | null;
} catch {
return null;
}
},
setUser: <T>(u: T) => localStorage.setItem(key('auth-user'), JSON.stringify(u)),
getProfileId: () => localStorage.getItem(key('profile-id')) ?? undefined,
setProfileId: (id: string) => localStorage.setItem(key('profile-id'), id),
setUser: <T>(u: T) => backend.set(key("auth-user"), JSON.stringify(u)),
getProfileId: () => backend.get(key("profile-id")) ?? undefined,
setProfileId: (id: string) => backend.set(key("profile-id"), id),
getProfile: <T = unknown>(): T | null => {
try {
return JSON.parse(localStorage.getItem(key('current-profile')) ?? 'null') as T | null;
return JSON.parse(
backend.get(key("current-profile")) ?? "null",
) as T | null;
} catch {
return null;
}
},
setProfile: <T>(p: T) => localStorage.setItem(key('current-profile'), JSON.stringify(p)),
removeProfile: () => localStorage.removeItem(key('current-profile')),
setProfile: <T>(p: T) =>
backend.set(key("current-profile"), JSON.stringify(p)),
removeProfile: () => backend.remove(key("current-profile")),
clear: () => {
[key('auth-token'), key('refresh-token'), key('auth-user'), key('profile-id'), key('current-profile')].forEach((k) =>
localStorage.removeItem(k),
);
document.cookie =
'auth-token=; path=/; expires=Thu, 01 Jan 1970 00:00:00 UTC; SameSite=Lax';
const prefixes = Array.from(new Set([_prefix, ...KNOWN_PREFIXES]));
prefixes.forEach((p) => {
AUTH_KEY_NAMES.forEach((k) => {
const fullKey = p ? `${p}-${k}` : k;
removeCookieCompletely(fullKey);
try {
localStorage.removeItem(fullKey);
} catch {
// ignore
}
});
});
try {
localStorage.removeItem("rememberMe");
} catch {
// ignore
}
try {
if (typeof Cookies !== "undefined") {
const allCookies = Cookies.get();
if (allCookies) {
Object.keys(allCookies).forEach((name) => {
removeCookieCompletely(name);
});
}
}
} catch {
// ignore
}
},
};

View File

@@ -1,8 +1,9 @@
import { authStorage } from './auth-storage';
import { authStorage } from "./auth-storage";
const BASE_API_URL =
(import.meta as { env?: Record<string, string> }).env?.['VITE_BASE_API_URL'] ??
'http://localhost:3001/api';
(import.meta as { env?: Record<string, string> }).env?.[
"VITE_BASE_API_URL"
] ?? "http://localhost:3001/api";
interface RefreshResponse {
token: string;
@@ -11,7 +12,7 @@ interface RefreshResponse {
export async function refreshAccessToken(): Promise<string> {
const refreshToken = authStorage.getRefreshToken();
if (!refreshToken) throw new Error('No refresh token available');
if (!refreshToken) throw new Error("No refresh token available");
// The IAM service exposes this as `refresh-token`; posting to `/auth/refresh`
// 404s, which the catch below turns into a silent logout.
@@ -23,7 +24,7 @@ export async function refreshAccessToken(): Promise<string> {
if (!response.ok) {
authStorage.clear();
throw new Error('Token refresh failed');
throw new Error("Token refresh failed");
}
const data: RefreshResponse = await response.json();

View File

@@ -1 +1,4 @@
export * from './lib/theme/ema-theme';
export * from './lib/date/date-displayer';
export * from './lib/date/use-date-displayer';
export * from './lib/date/ethiopic';

View File

@@ -0,0 +1,57 @@
import { ethTimeLabel, toAmharicDisplay } from './ethiopic';
/** Wire values carrying no time of day, e.g. a date column serialised as `2026-08-10`. */
const DATE_ONLY = /^\d{4}-\d{2}-\d{2}$/;
/** Midnight UTC — how backends commonly serialise a plain date column. */
const UTC_MIDNIGHT = /T00:00:00(\.0+)?Z$/;
/**
* The one way a date is shown to a user.
*
* Timestamps render in the viewer's local timezone — `2026-08-10T06:57:34.507Z`
* reads as `Aug 10, 2026 9:57am` in Addis. Values with no real time of day
* render as a bare date: a birth date or a licence expiry stamped `12:00am`
* reads as precision the data does not have. Those are also formatted in UTC,
* because parsing `2026-08-10` yields UTC midnight, and converting that to a
* behind-UTC local timezone would show the previous day.
*
* `language` is a parameter rather than read from i18next because each app runs
* a DEDICATED i18n instance, not the global singleton (see `app/i18n/config.ts`)
* — the same reason `localized()` in licensing.helpers.ts takes one. Components
* should not call this directly; use `useDateDisplayer()` so the text actually
* re-renders when the language changes.
*/
export function dateDisplayer(
value: string | number | Date | null | undefined,
language = 'en',
): string {
if (value === null || value === undefined || value === '') return '—';
const date = new Date(value);
if (Number.isNaN(date.getTime())) return '—';
const dateOnly =
typeof value === 'string' && (DATE_ONLY.test(value) || UTC_MIDNIGHT.test(value));
if (language.startsWith('am')) {
const day = toAmharicDisplay(date); // ሐምሌ 22/2018
return dateOnly ? day : `${day} - ${ethTimeLabel(date)}`;
}
const day = date.toLocaleDateString('en-US', {
month: 'short',
day: 'numeric',
year: 'numeric',
...(dateOnly ? { timeZone: 'UTC' } : {}),
});
if (dateOnly) return day;
// Intl gives "9:57 AM"; the house format is "9:57am".
const time = date
.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })
.replace(' ', '')
.toLowerCase();
return `${day} ${time}`;
}

View File

@@ -0,0 +1,69 @@
import { EthDateTime } from 'ethiopian-calendar-date-converter';
const EC_MONTHS_AM = [
'መስከረም', 'ጥቅምት', 'ኅዳር', 'ታህሳስ', 'ጥር', 'የካቲት',
'መጋቢት', 'ሚያዝያ', 'ግንቦት', 'ሰኔ', 'ሐምሌ', 'ነሐሴ', 'ጳጉሜን',
];
// EthDateTime.fromEuropeanDate() computes the day from a raw UTC-epoch
// difference. A local-midnight Date in any positive-UTC-offset timezone
// (e.g. Ethiopia, UTC+3) lands in the previous UTC day and converts to
// yesterday's Ethiopian date. Re-embedding the same Y/M/D at UTC noon fixes
// the day regardless of the runtime's timezone.
export function toEthDateTime(date: Date): EthDateTime {
const utcNoon = new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate(), 12),
);
return EthDateTime.fromEuropeanDate(utcNoon);
}
export function ethMonthName(date: Date): string {
try {
return EC_MONTHS_AM[toEthDateTime(date).month - 1] ?? '';
} catch {
return '';
}
}
export function toAmharicDisplay(date: Date): string {
try {
const eth = toEthDateTime(date);
return `${ethMonthName(date)} ${eth.date}/${eth.year}`;
} catch {
return date.toLocaleDateString('en-US');
}
}
export type EthPeriod = 'lelit' | 'tewat' | 'ken' | 'mata';
// Ethiopian day starts at 6am. Period is picked from the 24h hour; the
// displayed hour is the Western hour shifted 6, wrapped onto a 12-hour dial.
// Each period only spans 6 hours (not 12): ጠዋት/ማታ show 12,1..5, ቀን/ሌሊት show
// 6..11 — offering all 12 hours in every period would let a user pick e.g.
// "ጠዋት 7", which has no 06:0011:59 preimage.
export const ETH_PERIODS: { value: EthPeriod; label: string; hours: number[] }[] = [
{ value: 'tewat', label: 'ጠዋት', hours: [12, 1, 2, 3, 4, 5] }, // 06:0011:59
{ value: 'ken', label: 'ቀን', hours: [6, 7, 8, 9, 10, 11] }, // 12:0017:59
{ value: 'mata', label: 'ማታ', hours: [12, 1, 2, 3, 4, 5] }, // 18:0023:59
{ value: 'lelit', label: 'ሌሊት', hours: [6, 7, 8, 9, 10, 11] }, // 00:0005:59
];
export function toEthTime(h24: number): { period: EthPeriod; hour: number } {
const period: EthPeriod =
h24 < 6 ? 'lelit' : h24 < 12 ? 'tewat' : h24 < 18 ? 'ken' : 'mata';
return { period, hour: ((h24 + 6) % 12) || 12 };
}
export function fromEthTime(period: EthPeriod, hour: number): number {
// Inverse of the shift, then re-add the 12h that %12 discarded for the
// afternoon/night pair of periods.
const pm = period === 'ken' || period === 'mata';
return ((hour + 6) % 12) + (pm ? 12 : 0);
}
export function ethTimeLabel(date: Date): string {
const { period, hour } = toEthTime(date.getHours());
const minutes = String(date.getMinutes()).padStart(2, '0');
const label = ETH_PERIODS.find((p) => p.value === period)?.label ?? '';
return `${String(hour).padStart(2, '0')}:${minutes} ${label}`;
}

View File

@@ -0,0 +1,22 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { dateDisplayer } from './date-displayer';
/**
* The component-facing date formatter.
*
* A hook rather than a bare import so the calling component is subscribed to
* i18next: switching language re-renders it and the dates flip with everything
* else. `useTranslation()` with no instance argument resolves to the app's own
* <I18nextProvider> (portal router.tsx, backoffice AppProviders.tsx), which is
* what makes this work across two separate i18n instances.
*
* The returned function is stable per language, so it is safe — and required —
* as a useMemo/useCallback dependency.
*/
export function useDateDisplayer(): (
value: string | number | Date | null | undefined,
) => string {
const { i18n } = useTranslation();
return useCallback((value) => dateDisplayer(value, i18n.language), [i18n.language]);
}

View File

@@ -31,6 +31,24 @@ export const emaTheme = createTheme({
md: '0 4px 20px rgba(15,23,42,0.08)',
lg: '0 8px 30px rgba(15,23,42,0.12)',
},
components: {
// Mantine's stock scroll wrapper (NativeScrollArea) discards the
// max-height it's handed unless scrollAreaComponent is set, so a modal
// taller than the viewport just gets clipped with no way to scroll it.
// Making the body the scrollport here fixes every Modal/Drawer at once.
Modal: {
styles: {
content: { display: 'flex', flexDirection: 'column', maxHeight: '90dvh' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
Drawer: {
styles: {
content: { display: 'flex', flexDirection: 'column' },
body: { flex: '1 1 auto', minHeight: 0, overflowY: 'auto' },
},
},
},
other: {
heroGradient: 'linear-gradient(135deg, #3160b7 0%, #1fc29d 100%)',
},

View File

@@ -0,0 +1,14 @@
import { defineConfig } from 'vitest/config';
import { nxViteTsPaths } from '@nx/vite/plugins/nx-tsconfig-paths.plugin';
export default defineConfig({
root: __dirname,
cacheDir: '../../node_modules/.vite/libs/shared',
plugins: [nxViteTsPaths()],
test: {
watch: false,
globals: true,
environment: 'node',
reporters: ['default'],
},
});

View File

@@ -1,16 +1,23 @@
export * from './lib/input/BilingualInput';
export * from './lib/feedback/ConfirmModal';
export * from './lib/feedback/ApiErrorAlert';
export * from './lib/feedback/notify';
export * from './lib/feedback/FeatureUnavailable';
export * from './lib/feedback/EmptyState';
export * from './lib/feedback/ErrorState';
export * from './lib/layout/AppHeader';
export * from './lib/layout/AppSidebar';
export * from './lib/layout/AppTopNav';
export * from './lib/layout/nav-utils';
export * from './lib/layout/BrandAvatar';
export * from './lib/layout/ColorSchemeToggle';
export * from './lib/layout/LanguageSwitcher';
export * from './lib/layout/PageHeader';
export * from './lib/table/AdvancedTable';
export * from "./lib/input/BilingualInput";
export * from "./lib/input/AmharicDatePicker";
export * from "./lib/feedback/ConfirmModal";
export * from "./lib/feedback/ModalFooter";
export * from "./lib/feedback/ApiErrorAlert";
export * from "./lib/feedback/notify";
export * from "./lib/feedback/FeatureUnavailable";
export * from "./lib/feedback/EmptyState";
export * from "./lib/feedback/ErrorState";
export * from "./lib/layout/AppHeader";
export * from "./lib/layout/AppSidebar";
export * from "./lib/layout/AppTopNav";
export * from "./lib/layout/nav-utils";
export * from "./lib/layout/BrandAvatar";
export * from "./lib/layout/ColorSchemeToggle";
export * from "./lib/layout/LanguageSwitcher";
export * from "./lib/layout/PageHeader";
export * from "./lib/input/PasswordRequirements";
export * from "./lib/input/CountrySelect";
export * from "./lib/input/phone";
export * from "./lib/data/AdvancedTable";
export * from "./lib/feedback/use-error-handler";
export * from "./lib/data/useServerTable";

View File

@@ -0,0 +1,436 @@
import type { CSSProperties, ComponentPropsWithoutRef } from "react";
export interface MaritimeLoaderProps extends Omit<
ComponentPropsWithoutRef<"span">,
"children" | "color"
> {
/** Standalone size. Mantine's Loader size is used automatically when omitted. */
size?: number | string;
/** Standalone CSS color. Mantine's Loader color is used automatically when omitted. */
color?: string;
/** Accessible status text. */
label?: string;
}
const styles = `
.ema-loader {
position: relative;
display: inline-block;
width: calc(var(--ema-size, var(--loader-size, 80px)) * 1.88);
color: var(--ema-color, var(--loader-color, #075985));
vertical-align: middle;
}
.ema-loader__svg {
display: block;
width: 100%;
height: var(--ema-size, var(--loader-size, 80px));
overflow: visible;
filter: drop-shadow(
0 calc(var(--ema-size, var(--loader-size, 80px)) * 0.035)
calc(var(--ema-size, var(--loader-size, 80px)) * 0.04)
rgb(7 39 58 / 18%)
);
}
.ema-loader__ship {
transform-box: fill-box;
transform-origin: 50% 82%;
animation: ema-ship-float 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
.ema-loader__shadow {
fill: currentColor;
opacity: 0.12;
transform-box: fill-box;
transform-origin: center;
animation: ema-shadow-breathe 2.55s cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
.ema-loader__deck {
fill: currentColor;
opacity: 0.82;
}
.ema-loader__superstructure > path:first-child,
.ema-loader__bridge-top {
fill: #f8fbfc;
stroke: currentColor;
stroke-width: 2.4;
stroke-linejoin: round;
}
.ema-loader__bridge-top { stroke-width: 2; }
.ema-loader__window {
fill: #bfe9ff;
stroke: currentColor;
stroke-width: 0.7;
}
.ema-loader__cabin-line {
fill: currentColor;
opacity: 0.35;
}
.ema-loader__funnel > path:first-child {
fill: #eef3f5;
stroke: currentColor;
stroke-width: 2;
stroke-linejoin: round;
}
.ema-loader__green { fill: #078930; }
.ema-loader__yellow { fill: #fcd116; }
.ema-loader__red { fill: #da121a; }
.ema-loader__mast {
fill: currentColor;
stroke: currentColor;
stroke-width: 1.8;
stroke-linecap: round;
stroke-linejoin: round;
}
.ema-loader__flag-pole {
fill: none;
stroke: currentColor;
stroke-width: 1.7;
stroke-linecap: round;
}
.ema-loader__flag {
transform-box: fill-box;
transform-origin: left center;
animation: ema-flag-wave 0.95s ease-in-out infinite alternate;
}
.ema-loader__hull {
fill: #f8fbfc;
stroke: currentColor;
stroke-width: 1.4;
stroke-linejoin: round;
}
.ema-loader__lower-hull {
fill: currentColor;
opacity: 0.92;
}
.ema-loader__waterline {
fill: none;
stroke: rgb(255 255 255 / 52%);
stroke-width: 2.4;
stroke-linecap: round;
}
.ema-loader__bow-highlight {
fill: none;
stroke: rgb(255 255 255 / 46%);
stroke-width: 2.3;
stroke-linecap: round;
stroke-linejoin: round;
}
.ema-loader__portholes {
fill: #d7f2ff;
stroke: currentColor;
stroke-width: 0.8;
}
.ema-loader__cargo path {
fill: none;
stroke: rgb(255 255 255 / 22%);
stroke-width: 1;
}
.ema-loader__container-dark rect { fill: #3f4a52; }
.ema-loader__container-muted rect { fill: #7d8991; }
.ema-loader__container-steel rect { fill: #59656d; }
.ema-loader__container-light rect { fill: #aab2b8; }
.ema-loader__container-medium rect { fill: #6c7880; }
.ema-loader__water-back,
.ema-loader__water-front,
.ema-loader__foam {
fill: none;
stroke-linecap: round;
stroke-linejoin: round;
}
.ema-loader__water-back {
stroke: currentColor;
stroke-width: 5;
opacity: 0.28;
stroke-dasharray: 58 12;
animation: ema-water-back 2.8s linear infinite;
}
.ema-loader__water-front {
stroke: currentColor;
stroke-width: 6;
opacity: 0.55;
stroke-dasharray: 70 10;
animation: ema-water-front 1.9s linear infinite;
}
.ema-loader__foam {
stroke: rgb(255 255 255 / 78%);
stroke-width: 3;
stroke-dasharray: 11 7;
animation: ema-foam-drift 1.65s linear infinite;
}
@keyframes ema-ship-float {
0%, 100% { transform: translateY(1.5px) rotate(-0.65deg); }
50% { transform: translateY(-3px) rotate(0.65deg); }
}
@keyframes ema-shadow-breathe {
0%, 100% { transform: scaleX(1.02); opacity: 0.14; }
50% { transform: scaleX(0.9); opacity: 0.08; }
}
@keyframes ema-flag-wave {
from { transform: skewY(-3deg) scaleX(0.94); }
to { transform: skewY(3deg) scaleX(1.04); }
}
@keyframes ema-water-back {
to { stroke-dashoffset: -140; }
}
@keyframes ema-water-front {
to { stroke-dashoffset: 160; }
}
@keyframes ema-foam-drift {
to { stroke-dashoffset: -36; }
}
@media (prefers-reduced-motion: reduce) {
.ema-loader__ship,
.ema-loader__shadow,
.ema-loader__flag,
.ema-loader__water-back,
.ema-loader__water-front,
.ema-loader__foam {
animation: none;
}
}
@media (forced-colors: active) {
.ema-loader__green,
.ema-loader__yellow,
.ema-loader__red,
.ema-loader__container-dark rect,
.ema-loader__container-muted rect,
.ema-loader__container-steel rect,
.ema-loader__container-light rect,
.ema-loader__container-medium rect {
fill: currentColor;
}
}
.ema-loader__label {
display: block;
text-align: center;
font-size: 12px;
line-height: 1.2;
margin-top: 4px;
color: currentColor;
}
`;
function toCssSize(value: number | string | undefined) {
return typeof value === "number" ? `${value}px` : value;
}
export function MaritimeLoader({
size,
color,
label = "Loading maritime services",
className,
style,
...props
}: MaritimeLoaderProps) {
const cssVariables = {
...(size ? { "--ema-size": toCssSize(size) } : {}),
...(color ? { "--ema-color": color } : {}),
...style,
} as CSSProperties;
return (
<span
{...props}
className={["ema-loader", className].filter(Boolean).join(" ")}
style={cssVariables}
role="status"
aria-label={label}
>
<style>{styles}</style>
<svg
className="ema-loader__svg"
viewBox="0 0 260 138"
aria-hidden="true"
focusable="false"
>
<g className="ema-loader__shadow">
<ellipse cx="132" cy="113" rx="78" ry="7" />
</g>
<g className="ema-loader__ship">
<g className="ema-loader__cargo">
<g className="ema-loader__container-dark">
<rect x="60" y="54" width="27" height="17" rx="1.5" />
<path d="M66 56v13M73 56v13M80 56v13" />
</g>
<g className="ema-loader__container-steel">
<rect x="89" y="54" width="27" height="17" rx="1.5" />
<path d="M95 56v13M102 56v13M109 56v13" />
</g>
<g className="ema-loader__container-light">
<rect x="118" y="54" width="27" height="17" rx="1.5" />
<path d="M124 56v13M131 56v13M138 56v13" />
</g>
<g className="ema-loader__container-medium">
<rect x="147" y="54" width="27" height="17" rx="1.5" />
<path d="M153 56v13M160 56v13M167 56v13" />
</g>
<g className="ema-loader__container-dark">
<rect x="76" y="35" width="27" height="17" rx="1.5" />
<path d="M82 37v13M89 37v13M96 37v13" />
</g>
<g className="ema-loader__container-muted">
<rect x="105" y="35" width="27" height="17" rx="1.5" />
<path d="M111 37v13M118 37v13M125 37v13" />
</g>
<g className="ema-loader__container-dark">
<rect x="134" y="35" width="27" height="17" rx="1.5" />
<path d="M140 37v13M147 37v13M154 37v13" />
</g>
</g>
<path className="ema-loader__deck" d="M42 72H214l-4 6H48z" />
<g className="ema-loader__superstructure">
<path d="M174 41h27l10 31h-42z" />
<path className="ema-loader__bridge-top" d="M178 32h20l5 9h-27z" />
<rect
className="ema-loader__window"
x="179"
y="45"
width="7"
height="7"
rx="1"
/>
<rect
className="ema-loader__window"
x="188"
y="45"
width="7"
height="7"
rx="1"
/>
<rect
className="ema-loader__window"
x="197"
y="45"
width="7"
height="7"
rx="1"
/>
<rect
className="ema-loader__cabin-line"
x="180"
y="58"
width="20"
height="2.5"
rx="1.25"
/>
</g>
<g className="ema-loader__funnel">
<path d="M166 25h10l3 17h-15z" />
<path
className="ema-loader__green"
d="M166.9 29h9.9l.6 3.5h-11.1z"
/>
<path
className="ema-loader__yellow"
d="M166.2 32.5h11.2l.6 3.5h-12.4z"
/>
<path
className="ema-loader__red"
d="M165.6 36h12.4l.6 3.5h-13.6z"
/>
</g>
<g className="ema-loader__mast">
<path d="M194 31V12M188 20h12M194 13l11 8M194 13l-9 8" />
<circle cx="194" cy="11" r="2" />
</g>
<g>
<path className="ema-loader__flag-pole" d="M184 20V8" />
<g className="ema-loader__flag">
<path
className="ema-loader__green"
d="M184 8c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
/>
<path
className="ema-loader__yellow"
d="M184 12c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
/>
<path
className="ema-loader__red"
d="M184 16c7-3 11 3 18 0v4c-7 3-11-3-18 0z"
/>
</g>
</g>
<path
className="ema-loader__hull"
d="M28 76h205l-15 18c-8 10-20 15-33 15H66c-13 0-24-5-31-15L23 80c-2-2 0-4 5-4z"
/>
<path
className="ema-loader__lower-hull"
d="M34 88h190l-6 6c-8 10-20 15-33 15H66c-13 0-24-5-31-15z"
/>
<path className="ema-loader__waterline" d="M36 87h188" />
<path className="ema-loader__bow-highlight" d="M206 81l15 1-8 9" />
<g className="ema-loader__portholes">
<circle cx="66" cy="91" r="2.2" />
<circle cx="78" cy="91" r="2.2" />
<circle cx="90" cy="91" r="2.2" />
</g>
</g>
<g className="ema-loader__water-back">
<path d="M3 112c17-8 30-8 47 0s30 8 47 0 30-8 47 0 30 8 47 0 30-8 47 0 30 8 47 0" />
</g>
<g className="ema-loader__water-front">
<path d="M-8 121c19-9 34-9 53 0s34 9 53 0 34-9 53 0 34 9 53 0 34-9 53 0 34 9 53 0" />
</g>
<g className="ema-loader__foam">
<path d="M29 106c13 4 25 5 38 3" />
<path d="M200 108c14 1 24-1 35-5" />
</g>
</svg>
{label && <span className="ema-loader__label">{label}</span>}
</span>
);
}
export default MaritimeLoader;
// how to use this component.
// <Center style={{ width: "90vw", height: "90vh" }}>
// <MaritimeLoader
// size={120}
// color="#075985"
// label="Loading Maritime Services..."
// />
// </Center>

View File

@@ -0,0 +1,158 @@
# AdvancedTable
Server-paginated data table with a column-visibility ("View") menu. Built on Mantine `Table`. Portable — two files, no app-specific imports.
## Files
- `AdvancedTable.tsx` — the component.
- `useServerTable.ts` — small hook for page-index + search-query state (optional, but pairs with it).
To use in another project, copy both files as-is into that project and export them from your UI barrel (or import by relative path).
## Dependencies
```
@mantine/core (tested on ^8)
@tabler/icons-react (IconRefresh, IconEye, IconInbox)
react-i18next (useTranslation)
react (>=17, uses hooks)
```
If the target project doesn't use `react-i18next`, replace the `t(key, fallback)` calls with plain strings — the component only reads the fallback text, translation is not load-bearing.
## Install
```bash
npm install @mantine/core @tabler/icons-react react-i18next
```
Mantine must already be set up with `MantineProvider` in the app root — this component does not wrap one.
## Copy the source
Copy `AdvancedTable.tsx` and `useServerTable.ts` into the new project (e.g. `src/components/table/`). No modifications needed unless you're renaming the i18n keys.
## API
### `AdvancedColumn<T>`
| Field | Type | Required | Notes |
|---|---|---|---|
| `header` | `ReactNode` | yes | Column heading, also used as the label in the View menu. |
| `accessorKey` | `string` | no | Dot-path into the row (e.g. `"expectation.name"`), used when `cell` is omitted. |
| `cell` | `(ctx: { row: { original: T }; value: unknown }) => ReactNode` | no | Custom cell renderer. Takes priority over `accessorKey`. |
| `size` | `number` | no | Column width in px. |
| `align` | `'left' \| 'center' \| 'right'` | no | Text alignment for header + cells. |
| `enabled` | `boolean` | no | Whether the column starts **visible**. Default `true`. Toggled at runtime via the View menu. |
### `AdvancedTable<T>` props
| Prop | Type | Required | Notes |
|---|---|---|---|
| `columns` | `AdvancedColumn<T>[]` | yes | |
| `data` | `T[]` | yes | Rows for the **current page** only — not the full dataset. |
| `tableName` | `string` | yes | Shown as the table title. |
| `itemCount` | `number` | yes | Total row count on the server. Drives pagination and the count badge — not `data.length`. |
| `pageIndex` | `number` | yes | 0-based current page. |
| `onPageChange` | `(pageIndex: number) => void` | yes | |
| `pageSize` | `number` | no | Default `10`. Pagination only renders when `itemCount > pageSize`. |
| `onPageSizeChange` | `(pageSize: number) => void` | no | Shows a page-size `<Select>` (10/20/30/40/50 by default) next to the pagination when given. |
| `pageSizeOptions` | `number[]` | no | Options for the page-size select. Default `[10, 20, 30, 40, 50]`. |
| `refresh` | `() => void` | no | Shows a Refresh button when provided. |
| `onSearchChange` | `(q: string) => void` | no | Reserved for a search box; not currently rendered by the component itself (wire your own input and call this, or drive `useServerTable`'s `setQ`). |
| `isLoading` | `boolean` | no | Shows a loader row; also spins the Refresh button. |
| `emptyText` | `string` | no | Message when `data` is empty. |
Rows should have an `id: string \| number` field — used as the React key (falls back to row index if absent).
### `useServerTable(opts?)`
```ts
const { pageIndex, setPageIndex, q, setQ, pageSize, setPageSize, skip, take } = useServerTable({ pageSize: 10 });
```
Centralizes page-index + search-query state for a server-paginated list. `setQ` and `setPageSize` both reset `pageIndex` back to 0. `skip`/`take` are ready to drop into an offset-based API call. Wire `setPageSize` into `AdvancedTable`'s `onPageSizeChange` to expose the page-size select.
## Behavior notes
- **Column visibility** is local UI state (`useState`), re-initialized from each column's `enabled` on mount — it does not persist across reloads or sync back to the caller.
- At least one column always stays visible; the View menu disables unchecking the last one.
- The View menu closes only via outside click (`closeOnItemClick={false}`), so multiple columns can be toggled per open.
## Usage
### Basic
```tsx
import { AdvancedTable, type AdvancedColumn } from './table/AdvancedTable';
interface User {
id: string;
name: string;
email: string;
}
const columns: AdvancedColumn<User>[] = [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Email', accessorKey: 'email', enabled: true },
];
<AdvancedTable
columns={columns}
data={users}
tableName="Users"
itemCount={users.length}
pageIndex={0}
onPageChange={() => {}}
/>
```
### Server-paginated, with refresh and a custom action column
```tsx
import { AdvancedTable, useServerTable, type AdvancedColumn } from './table/AdvancedTable';
function UsersTable() {
const { pageIndex, setPageIndex, skip, take } = useServerTable({ pageSize: 10 });
const { data, isFetching, refetch } = useGetUsersQuery({ skip, take });
const users = data?.items ?? [];
const totalCount = data?.total ?? 0;
const columns: AdvancedColumn<User>[] = [
{ header: 'Name', accessorKey: 'name' },
{ header: 'Email', accessorKey: 'email' },
{
header: 'Status',
align: 'center',
cell: ({ row }) => (row.original.active ? 'Active' : 'Inactive'),
},
{
header: '',
size: 80,
cell: ({ row }) => (
<ActionIcon onClick={() => onEdit(row.original)}>
<IconEdit size={14} />
</ActionIcon>
),
},
];
return (
<AdvancedTable
columns={columns}
data={users}
tableName="Users"
itemCount={totalCount}
pageIndex={pageIndex}
onPageChange={setPageIndex}
pageSize={10}
refresh={refetch}
isLoading={isFetching}
emptyText="No users found"
/>
);
}
```
Real reference implementation: `apps/backoffice/src/app/features/configuration/pages/ConfigurationPage.tsx` (`ProfessionTab`).

View File

@@ -0,0 +1,259 @@
import { CSSProperties, ReactNode, useState } from "react";
import {
Table,
Button,
Menu,
Checkbox,
Group,
Text,
Pagination,
Loader,
Center,
Paper,
Select,
} from "@mantine/core";
import { IconRefresh, IconAdjustmentsHorizontal , IconInbox, } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export interface AdvancedColumn<T> {
header: ReactNode;
/** Dot-path into the row, used when no `cell` is given (e.g. "expectation.name"). */
accessorKey?: string;
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
size?: number;
align?: "left" | "center" | "right";
/** Whether column starts visible. Default true. */
enabled?: boolean;
/** Label for the View menu; falls back to `header` when it is a plain string. */
label?: string;
}
interface AdvancedTableProps<T> {
columns: AdvancedColumn<T>[];
data: T[];
tableName: string;
/** Total item count on the server (drives pagination), not data.length. */
itemCount: number;
/** 0-based current page. */
pageIndex: number;
onPageChange: (pageIndex: number) => void;
pageSize?: number;
/** Shows a page-size <Select> when given; called with the chosen size. */
onPageSizeChange?: (pageSize: number) => void;
/** Options for the page-size select. Default [10, 20, 30, 40, 50]. */
pageSizeOptions?: number[];
refresh?: () => void;
/** Server-side search — debounced internally. Omit to hide the search box. */
onSearchChange?: (q: string) => void;
isLoading?: boolean;
emptyText?: string;
verticalSpacing?: string | number;
rowStyle?: (row: T, index: number) => CSSProperties | undefined;
/** Makes rows clickable (adds pointer cursor). */
onRowClick?: (row: T) => void;
}
function getByPath(obj: unknown, path?: string): unknown {
if (!path) return undefined;
return path
.split(".")
.reduce<unknown>(
(acc, key) =>
acc && typeof acc === "object"
? (acc as Record<string, unknown>)[key]
: undefined,
obj,
);
}
export function AdvancedTable<T extends { id?: string | number }>({
columns,
data,
tableName,
itemCount,
pageIndex,
onPageChange,
pageSize = 10,
onPageSizeChange,
pageSizeOptions = [10, 20, 30, 40, 50],
refresh,
isLoading = false,
emptyText,
verticalSpacing = "sm",
rowStyle,
onRowClick,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(
columns.map((c) => c.enabled ?? true),
);
const toggleColumn = (i: number) =>
setVisible((prev) => {
if (prev[i] && prev.filter(Boolean).length === 1) return prev; // keep at least one column visible
return prev.map((v, idx) => (idx === i ? !v : v));
});
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
return (
<Paper withBorder radius="md" p="md">
<Group justify="space-between" mb="md">
<Group gap="xs">
<Text fw={600}>{""}</Text>
</Group>
<Group gap="xs">
{refresh && (
<Button
variant="default"
size="sm"
leftSection={<IconRefresh size={16} />}
onClick={refresh}
loading={isLoading}
>
{t("common.refresh", "Refresh")}
</Button>
)}
<Menu
closeOnItemClick={false}
shadow="md"
position="bottom-end"
width={220}
>
<Menu.Target>
<Button
variant="default"
size="sm"
leftSection={<IconAdjustmentsHorizontal size={16} />}
>
{t("common.view", "View")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>
{t("common.toggleColumns", "Toggle columns")}
</Menu.Label>
{columns.map((col, i) => {
// A ReactNode header (e.g. a live checkbox or a clickable sort
// control) can't be reused as a menu-item label — skip it
// rather than nesting interactive markup inside the label.
const label = col.label ?? (typeof col.header === "string" ? col.header : null);
if (label === null) return null;
return (
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
<Checkbox
label={label}
checked={visible[i] ?? true}
disabled={
visible.filter(Boolean).length === 1 &&
(visible[i] ?? true)
}
readOnly
tabIndex={-1}
styles={{
input: { cursor: "pointer" },
label: { cursor: "pointer" },
}}
/>
</Menu.Item>
);
})}
</Menu.Dropdown>
</Menu>
</Group>
</Group>
<Table.ScrollContainer minWidth={480}>
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing={verticalSpacing}
>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
<Table.Th
key={i}
style={{ width: col.size, textAlign: col.align }}
>
{col.header}
</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{isLoading ? (
<Table.Tr>
<Table.Td colSpan={shownColumns.length}>
<Center py="xl">
<Loader size="sm" />
</Center>
</Table.Td>
</Table.Tr>
) : data.length === 0 ? (
<Table.Tr>
<Table.Td colSpan={shownColumns.length}>
<Center py="xl">
<Group gap="xs" c="dimmed">
<IconInbox size={18} />
<Text c="dimmed">
{emptyText ?? t("common.noResult", "No results")}
</Text>
</Group>
</Center>
</Table.Td>
</Table.Tr>
) : (
data.map((row, rowIndex) => (
<Table.Tr
key={row.id ?? rowIndex}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{
...(onRowClick ? { cursor: "pointer" } : undefined),
...rowStyle?.(row, rowIndex),
}}
>
{shownColumns.map((col, i) => {
const value = getByPath(row, col.accessorKey);
return (
<Table.Td key={i} style={{ textAlign: col.align }}>
{col.cell
? col.cell({ row: { original: row }, value })
: ((value as ReactNode) ?? "-")}
</Table.Td>
);
})}
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{(itemCount > pageSize || onPageSizeChange) && (
<Group justify="flex-end" mt="md">
{onPageSizeChange && (
<Select
size="sm"
w={100}
data={pageSizeOptions.map((n) => String(n))}
value={String(pageSize)}
onChange={(v) => v && onPageSizeChange(Number(v))}
allowDeselect={false}
/>
)}
{itemCount > pageSize && (
<Pagination
total={Math.ceil(itemCount / pageSize)}
value={pageIndex + 1}
onChange={(page) => onPageChange(page - 1)}
size="sm"
siblings={0}
boundaries={1}
/>
)}
</Group>
)}
</Paper>
);
}

View File

@@ -0,0 +1,53 @@
import { useState, useCallback } from 'react';
interface UseServerTableOptions {
pageSize?: number;
}
/**
* Centralizes the page-index + search-query state a server-paginated table
* needs. Changing the search query resets paging back to page 0.
*/
export function useServerTable({ pageSize: initialPageSize = 10 }: UseServerTableOptions = {}) {
const [pageIndex, setPageIndex] = useState(0);
const [pageSize, setPageSizeInternal] = useState(initialPageSize);
const [q, setQInternal] = useState('');
const setQ = useCallback((value: string) => {
setQInternal(value);
setPageIndex(0);
}, []);
const setPageSize = useCallback((value: number) => {
setPageSizeInternal(value);
setPageIndex(0);
}, []);
// Slice an already-fetched array for AdvancedTable when the endpoint has no
// skip/take of its own. Clamps pageIndex so deleting the last row of the
// last page doesn't strand the table on an empty slice.
const paginate = useCallback(
<T,>(rows: T[]) => {
const lastPage = Math.max(0, Math.ceil(rows.length / pageSize) - 1);
const clamped = Math.min(pageIndex, lastPage);
return {
rows: rows.slice(clamped * pageSize, clamped * pageSize + pageSize),
pageIndex: clamped,
itemCount: rows.length,
};
},
[pageIndex, pageSize],
);
return {
pageIndex,
setPageIndex,
q,
setQ,
pageSize,
setPageSize,
skip: pageIndex * pageSize,
take: pageSize,
paginate,
};
}

View File

@@ -1,4 +1,5 @@
import { Modal, Button, Group, Text } from '@mantine/core';
import { Modal, Button, Text } from '@mantine/core';
import { ModalFooter } from './ModalFooter';
interface ConfirmModalProps {
opened: boolean;
@@ -26,14 +27,14 @@ export function ConfirmModal({
<Text size="sm" mb="xl">
{message}
</Text>
<Group justify="flex-end">
<ModalFooter>
<Button variant="subtle" onClick={onClose} disabled={loading}>
{cancelLabel}
</Button>
<Button color="red" onClick={onConfirm} loading={loading}>
{confirmLabel}
</Button>
</Group>
</ModalFooter>
</Modal>
);
}

View File

@@ -0,0 +1,38 @@
import { Group, type GroupProps } from '@mantine/core';
import type { CSSProperties } from 'react';
/**
* Action row pinned to the bottom of a modal.
*
* Modal bodies scroll (see the Modal styles in the theme), which would carry the
* confirm button off-screen on a tall modal. Sticking to the bottom of the
* scrollport keeps the decision reachable from any scroll position. Negative
* margins bleed the background over the body's padding so nothing shows through
* underneath; on a modal short enough not to scroll this renders identically to
* a plain Group.
*/
export function ModalFooter({
children,
style,
...props
}: Omit<GroupProps, 'style'> & { style?: CSSProperties }) {
return (
<Group
justify="flex-end"
{...props}
style={{
position: 'sticky',
bottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginInline: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
marginBottom: 'calc(var(--mb-padding, var(--mantine-spacing-md)) * -1)',
padding: 'var(--mb-padding, var(--mantine-spacing-md))',
background: 'var(--mantine-color-body)',
borderTop: '1px solid var(--mantine-color-default-border)',
zIndex: 1,
...style,
}}
>
{children}
</Group>
);
}

View File

@@ -0,0 +1,72 @@
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { notify } from './notify';
// Recursive: first non-empty string in a string | array | { message | error | detail }. Never throws.
function extractMessage(value: unknown): string | null {
if (value == null) return null;
if (typeof value === 'string') return value.trim() || null;
if (Array.isArray(value)) {
const parts = value.map(extractMessage).filter(Boolean) as string[];
return parts.length ? parts.join(', ') : null;
}
if (typeof value === 'object') {
const o = value as Record<string, unknown>;
return extractMessage(o.message) ?? extractMessage(o.error) ?? extractMessage(o.detail) ?? null;
}
return null;
}
// HTTP status OR RTK string status (FETCH_ERROR/TIMEOUT_ERROR/PARSING_ERROR/CUSTOM_ERROR) → i18n key.
function statusKeyFor(status: number | string | undefined): string {
if (status === 400 || status === 422) return 'msg.validationError';
if (status === 401) return 'msg.authError';
if (status === 403) return 'msg.permissionError';
if (status === 404) return 'msg.notFoundError';
if (status === 413) return 'msg.fileTooLarge';
if (typeof status === 'number' && status >= 500) return 'msg.serverError';
if (typeof status === 'string') return 'msg.networkError';
return 'msg.genericError';
}
function logError(err: unknown): void {
if (err && typeof err === 'object' && 'status' in err) {
console.error('[API ERROR]', (err as { status?: unknown }).status, (err as { data?: unknown }).data);
return;
}
console.error('Error caught:', err);
}
export function useErrorHandler() {
const { t } = useTranslation();
// Priority: backend message (FetchBaseQueryError.data) → Error.message → status/network fallback key.
const getErrorMessage = useCallback(
(err: unknown): string => {
let status: number | string | undefined;
if (err && typeof err === 'object' && ('status' in err || 'data' in err)) {
status = (err as { status?: number | string }).status;
const fromData = extractMessage((err as { data?: unknown }).data);
if (fromData) return fromData;
}
if (err instanceof Error) {
const fromError = extractMessage(err.message);
if (fromError) return fromError;
}
return t(statusKeyFor(status));
},
[t],
);
const handleError = useCallback(
(err: unknown): string => {
logError(err);
const message = getErrorMessage(err);
notify.error(message);
return message;
},
[getErrorMessage],
);
return { getErrorMessage, handleError };
}

View File

@@ -0,0 +1,224 @@
/* Restyle the react-day-picker month/year dropdown caption to match Mantine
inputs — the library ships it as bare text + an invisible <select>, with
no border/box affordance and a hardcoded blue chevron. */
.amharic-daypicker {
--rdp-accent-color: var(--mantine-primary-color-filled);
--rdp-day-height: 36px;
--rdp-day-width: 36px;
--rdp-day_button-height: 34px;
--rdp-day_button-width: 34px;
--rdp-weekday-padding: 0.25rem 0;
--rdp-nav-height: 2.25rem;
--rdp-nav_button-width: 2rem;
--rdp-nav_button-height: 2rem;
}
/* Backoffice loads Tailwind's preflight after the package stylesheet. That
reset can strip the day-picker table layout, leaving every weekday and day
stacked in a single column. Keep the calendar's structural styles local to
the component so it renders consistently in every app. */
.amharic-daypicker .rdp-months {
position: relative;
display: flex;
max-width: fit-content;
}
.amharic-daypicker .rdp-month {
position: relative;
}
.amharic-daypicker .rdp-month_grid {
display: table;
border-collapse: collapse;
}
.amharic-daypicker .rdp-month_grid thead {
display: table-header-group;
}
.amharic-daypicker .rdp-month_grid tbody {
display: table-row-group;
}
.amharic-daypicker .rdp-month_grid tr {
display: table-row;
}
.amharic-daypicker .rdp-weekday,
.amharic-daypicker .rdp-day {
display: table-cell;
width: var(--rdp-day-width);
height: var(--rdp-day-height);
text-align: center;
}
.amharic-daypicker .rdp-weekday {
padding: var(--rdp-weekday-padding);
}
.amharic-daypicker .rdp-day_button {
padding: 0;
margin: 0;
border: 2px solid transparent;
border-radius: 100%;
background: none;
color: inherit;
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rdp-day_button-width);
height: var(--rdp-day_button-height);
}
.amharic-daypicker .rdp-nav {
position: absolute;
inset-block-start: 0;
inset-inline-end: 0;
display: flex;
align-items: center;
height: var(--rdp-nav-height);
z-index: 10;
pointer-events: none;
}
.amharic-daypicker .rdp-button_previous,
.amharic-daypicker .rdp-button_next {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--rdp-nav_button-width);
height: var(--rdp-nav_button-height);
padding: 0;
margin: 0;
position: relative;
z-index: 10;
pointer-events: auto;
}
.amharic-daypicker .rdp-dropdowns {
gap: 0.375rem;
}
.amharic-daypicker .rdp-dropdown_root {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-body);
padding: 0.25rem 0.5rem;
}
.amharic-daypicker .rdp-dropdown_root:hover {
background-color: var(--mantine-color-default-hover);
}
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-dropdown {
color-scheme: dark;
background-color: var(--mantine-color-dark-7);
color: var(--mantine-color-gray-1);
}
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-dropdown option {
background-color: var(--mantine-color-dark-7);
color: var(--mantine-color-gray-1);
}
.amharic-daypicker .rdp-caption_label {
gap: 0.25rem;
font-size: var(--mantine-font-size-sm);
font-weight: 500;
color: var(--mantine-color-text);
}
.amharic-daypicker .rdp-dropdown_root .rdp-chevron {
width: 12px;
height: 12px;
fill: var(--mantine-color-dimmed);
}
/* Prev/next month buttons: bigger tap target + visible button box (border,
background, accent-colored chevron) instead of the default bare, tiny
blue arrow — the dropdown captions replaced them as the primary nav, so
they need to stay easy to spot and hit. */
.amharic-daypicker .rdp-button_previous,
.amharic-daypicker .rdp-button_next {
border: 1px solid var(--mantine-color-default-border);
border-radius: var(--mantine-radius-sm);
background-color: var(--mantine-color-body);
}
.amharic-daypicker .rdp-button_previous:not([aria-disabled='true']):hover,
.amharic-daypicker .rdp-button_next:not([aria-disabled='true']):hover {
background-color: var(--mantine-color-default-hover);
}
.amharic-daypicker .rdp-nav .rdp-chevron {
width: 20px;
height: 20px;
fill: var(--mantine-primary-color-filled);
}
.amharic-daypicker .rdp-month_caption {
font-size: inherit;
font-weight: 600;
margin-inline-end: calc(var(--rdp-nav_button-width) * 2 + 0.5rem);
}
/* Day cells are already circular (--rdp-day_button-border-radius: 100%);
the library only outlines the selected one by default — fill it instead
so the selection reads as a solid, unambiguous mark. */
.amharic-daypicker .rdp-selected .rdp-day_button {
background-color: var(--mantine-primary-color-filled);
border-color: var(--mantine-primary-color-filled);
color: var(--mantine-color-white);
}
/* Today indicator — a solid accent-coloured ring that stays clearly visible
in both light and dark themes. The border uses the primary filled colour so
it stands out against the popup background on either scheme. The text gets
the primary colour so the digit is unambiguously "special" even without the
ring being thick. */
.amharic-daypicker .rdp-today .rdp-day_button {
border: 2px solid var(--mantine-primary-color-filled) !important;
color: var(--mantine-primary-color-filled);
font-weight: 700;
}
/* When today is also the selected day, keep the filled background but add a
contrasting inner ring so the "today" mark isn't lost. */
.amharic-daypicker .rdp-today.rdp-selected .rdp-day_button {
background-color: var(--mantine-primary-color-filled);
border-color: var(--mantine-primary-color-filled) !important;
color: var(--mantine-color-white);
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.5);
}
/* Dark-mode: bright border ring + bright text, NO filled background.
A filled background can end up white on dark surfaces; a ring + coloured
digit is always visible regardless of the popup colour scheme. */
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-today .rdp-day_button {
background-color: transparent !important;
border: 2px solid #74c0fc !important;
color: #74c0fc !important;
font-weight: 700;
}
/* When today is also selected in dark mode: keep the primary filled background
but use the bright ring so today is still distinguishable from other selected days. */
[data-mantine-color-scheme='dark'] .amharic-daypicker .rdp-today.rdp-selected .rdp-day_button {
background-color: var(--mantine-primary-color-filled) !important;
border: 2px solid #74c0fc !important;
color: #fff !important;
box-shadow: inset 0 0 0 2px rgba(255, 255, 255, 0.3);
}
/* Ensure no scrollbar appears on the datepicker popover dropdown */
.amharic-daypicker-dropdown {
overflow: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
.amharic-daypicker-dropdown::-webkit-scrollbar {
display: none !important;
width: 0 !important;
height: 0 !important;
}

View File

@@ -0,0 +1,403 @@
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
ActionIcon,
Button,
Group,
MantineSize,
Popover,
SegmentedControl,
Select,
Stack,
Text,
TextInput,
} from '@mantine/core';
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 { IconCalendarEvent } from '@tabler/icons-react';
import '@daypicker/react/dist/style.css';
import './AmharicDatePicker.css';
import {
type EthPeriod,
ETH_PERIODS,
ethMonthName,
ethTimeLabel,
fromEthTime,
toAmharicDisplay,
toEthDateTime,
toEthTime,
} from '@ema-platform/shared';
export type { EthPeriod };
// react-day-picker calls these with the Gregorian Date it tracks internally;
// override so the caption/dropdown show Amharic month names instead of the
// library's Latin transliteration (triggered by numerals="latn" below).
const ETH_FORMATTERS = {
formatCaption: (month: Date) =>
`${ethMonthName(month)} ${toEthDateTime(month).year}`,
formatMonthDropdown: (month: Date) => ethMonthName(month),
};
type DateWireFormat = 'iso' | 'date';
// yyyy-MM-dd, parsed/formatted as a LOCAL calendar date — no Date-object/UTC
// round-trip at all, so it can't suffer the timezone off-by-one class of bug
// the ISO path below works around. This is the shape filter query params and
// plain `date: string` DTO fields expect.
function parsePlainDate(value: string): Date | null {
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
if (!m) return null;
return new Date(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
}
function formatPlainDate(date: Date): string {
const y = date.getFullYear();
const m = String(date.getMonth() + 1).padStart(2, '0');
const d = String(date.getDate()).padStart(2, '0');
return `${y}-${m}-${d}`;
}
// ISO wire format is a full ISO-8601 instant string (e.g.
// "2026-07-31T00:00:00.000Z"), what most backend date fields expect. When
// `withTime` is off, the picker only selects a calendar day, so the time is
// pinned to UTC midnight — reading back with getUTC*() (not local get*())
// keeps the calendar day stable regardless of the runtime's timezone,
// avoiding the same off-by-one class of bug the UTC-noon workaround above
// exists for.
function parseWireValue(
value: string | null | undefined,
format: DateWireFormat,
withTime: boolean,
): Date | null {
if (!value) return null;
if (format === 'date') return parsePlainDate(value);
const d = new Date(value);
if (isNaN(d.getTime())) return null;
return withTime
? d
: new Date(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
}
function formatWireValue(
date: Date,
format: DateWireFormat,
withTime: boolean,
): string {
if (format === 'date') return formatPlainDate(date);
return withTime
? date.toISOString()
: new Date(
Date.UTC(date.getFullYear(), date.getMonth(), date.getDate()),
).toISOString();
}
// Combines a calendar day with a time-of-day, keeping whichever half isn't
// changing. `base` is the currently selected Date (may be null if nothing
// picked yet); `day`/`time` override only the half that's provided.
function mergeDateTime(
base: Date | null,
day?: Date,
time?: { hours: number; minutes: number },
): Date {
const result = day ? new Date(day) : new Date(base ?? new Date());
if (time) {
result.setHours(time.hours, time.minutes, 0, 0);
} else if (day && base) {
result.setHours(base.getHours(), base.getMinutes(), base.getSeconds(), 0);
}
return result;
}
const YEAR_DROPDOWN_START = new Date(new Date().getFullYear() - 100, 0, 1);
const YEAR_DROPDOWN_END = new Date(new Date().getFullYear() + 50, 11, 31);
export interface AmharicDatePickerProps {
label?: React.ReactNode;
value?: string | null;
onChange?: (value: string) => void;
required?: boolean;
placeholder?: string;
error?: React.ReactNode;
disabled?: boolean;
size?: MantineSize;
name?: string;
onBlur?: () => void;
/** Width of the input, same as Mantine's `w` on TextInput/Select — needed
* to line this field up with siblings in a filter bar. */
w?: number | string;
/** Show a time-of-day field alongside the calendar. Off by default —
* most callers only need a calendar day. */
withTime?: boolean;
/** 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
* expect). */
dateFormat?: DateWireFormat;
}
export function AmharicDatePicker({
label,
value,
onChange,
required,
placeholder,
error,
disabled,
size,
name,
onBlur,
w,
withTime = false,
dateFormat = 'iso',
}: AmharicDatePickerProps) {
const { t, i18n } = useTranslation();
const [calendarType, setCalendarType] = useState<'EN' | 'AMH'>(() =>
i18n.language?.startsWith('am') ? 'AMH' : 'EN',
);
const [opened, { close, toggle }] = useDisclosure(false);
const selected = parseWireValue(value, dateFormat, withTime);
const dateLabel = selected
? calendarType === 'EN'
? selected.toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
: toAmharicDisplay(selected)
: '';
const timeLabel =
withTime && selected
? calendarType === 'AMH'
? ` ${ethTimeLabel(selected)}`
: ` ${selected.toLocaleTimeString('en-US', { hour: 'numeric', minute: '2-digit' })}`
: '';
const displayValue = dateLabel + timeLabel;
return (
<Popover
opened={opened}
onChange={close}
position="bottom-start"
width="auto"
trapFocus
withArrow
withinPortal
shadow="md"
radius="md"
// The field lives inside a Modal's scrollable body while the dropdown is
// portaled to <body> — a different scroll container than its reference.
// The default `absolute` strategy sums offsets across that scroll chain
// and can get it wrong (dropdown flipped off-screen, or not following
// the field as the modal scrolls). `fixed` positions purely off the
// reference's viewport rect, sidestepping that.
floatingStrategy="fixed"
// Prevents the dropdown from re-flipping position mid-interaction —
// switching months resizes the grid (Pagume has far fewer days), which
// otherwise nudges the floating box right as a nav click lands, making
// the click miss.
preventPositionChangeWhenVisible
>
<Popover.Target>
<TextInput
label={label}
required={required}
value={displayValue}
readOnly
disabled={disabled}
size={size}
name={name}
w={w}
error={error}
placeholder={placeholder}
onClick={() => !disabled && toggle()}
onBlur={onBlur}
styles={{
input: {
cursor: disabled ? 'not-allowed' : 'pointer',
},
}}
leftSection={
<Button
type="button"
variant="subtle"
size="compact-xs"
tabIndex={-1}
disabled={disabled}
onClick={(e) => {
e.stopPropagation();
setCalendarType((prev) => (prev === 'EN' ? 'AMH' : 'EN'));
}}
aria-label={t('common.switchCalendar')}
styles={{
root: {
paddingLeft: 6,
paddingRight: 6,
fontWeight: 600,
fontSize: '0.75rem',
},
}}
>
{calendarType}
</Button>
}
leftSectionWidth="3.25rem"
rightSection={
<ActionIcon size="sm" variant="transparent" disabled={disabled} onClick={() => toggle()}>
<IconCalendarEvent size={18} />
</ActionIcon>
}
/>
</Popover.Target>
{/* Inside a Modal the dropdown is portaled out of the modal body so it positions nicely. */}
<Popover.Dropdown className="amharic-daypicker-dropdown" p="sm" style={{ overflow: 'hidden' }}>
{calendarType === 'AMH' ? (
<EthiopicDayPicker
className="amharic-daypicker"
mode="single"
selected={selected ?? undefined}
defaultMonth={selected ?? undefined}
startMonth={YEAR_DROPDOWN_START}
endMonth={YEAR_DROPDOWN_END}
numerals="latn"
captionLayout="dropdown"
formatters={ETH_FORMATTERS}
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
if (!withTime) close();
}}
/>
) : (
<GregorianDayPicker
className="amharic-daypicker"
mode="single"
selected={selected ?? undefined}
defaultMonth={selected ?? undefined}
startMonth={YEAR_DROPDOWN_START}
endMonth={YEAR_DROPDOWN_END}
captionLayout="dropdown"
onSelect={(date: Date | undefined) => {
onChange?.(date ? formatWireValue(mergeDateTime(selected, date), dateFormat, withTime) : '');
if (!withTime) close();
}}
/>
)}
{withTime && calendarType === 'AMH' && (() => {
const { period, hour } = selected ? toEthTime(selected.getHours()) : { period: 'tewat' as EthPeriod, hour: 12 };
const minute = selected ? selected.getMinutes() : 0;
const periodHours = ETH_PERIODS.find((p) => p.value === period)?.hours ?? ETH_PERIODS[0].hours;
const setTime = (h24: number, m: number) =>
onChange?.(
formatWireValue(mergeDateTime(selected, undefined, { hours: h24, minutes: m }), dateFormat, true),
);
return (
<Stack gap={4} mt="sm">
<Text size="sm" fw={500}>
{t('common.time')}
</Text>
{/* Full-width 4-way toggle reads better than a dropdown for 4
short Amharic words, and doesn't get squeezed by the
calendar's fixed (auto) popover width the way three
side-by-side Selects did. */}
<SegmentedControl
fullWidth
size="xs"
disabled={!selected}
value={period}
data={ETH_PERIODS.map((p) => ({ value: p.value, label: p.label }))}
onChange={(next) => {
// Keep the same slot index within the new period so the
// hour never lands outside its 6-hour range.
const slot = Math.max(periodHours.indexOf(hour), 0);
const nextHours = ETH_PERIODS.find((p) => p.value === next)?.hours ?? periodHours;
setTime(fromEthTime(next as EthPeriod, nextHours[slot]), minute);
}}
/>
<Group gap={4} justify="center" wrap="nowrap">
<Select
disabled={!selected}
value={String(hour)}
data={periodHours.map((h) => ({ value: String(h), label: String(h) }))}
allowDeselect={false}
w={72}
comboboxProps={{ withinPortal: true }}
onChange={(next) => {
if (!next) return;
setTime(fromEthTime(period, Number(next)), minute);
}}
/>
<Text size="sm" c="dimmed">
:
</Text>
<Select
disabled={!selected}
value={String(minute).padStart(2, '0')}
data={Array.from({ length: 60 }, (_, m) => ({
value: String(m).padStart(2, '0'),
label: String(m).padStart(2, '0'),
}))}
allowDeselect={false}
w={72}
comboboxProps={{ withinPortal: true }}
onChange={(next) => {
if (next == null) return;
setTime(fromEthTime(period, hour), Number(next));
}}
/>
</Group>
</Stack>
);
})()}
{withTime && calendarType === 'EN' && (
<TimeInput
label={t('common.time')}
mt="sm"
disabled={!selected}
value={
selected
? `${String(selected.getHours()).padStart(2, '0')}:${String(selected.getMinutes()).padStart(2, '0')}`
: ''
}
onChange={(e) => {
const [h, m] = e.currentTarget.value.split(':').map(Number);
if (Number.isNaN(h) || Number.isNaN(m)) return;
onChange?.(
formatWireValue(mergeDateTime(selected, undefined, { hours: h, minutes: m }), dateFormat, true),
);
}}
/>
)}
<Group justify="space-between" mt="sm">
<Button
variant="subtle"
size="xs"
onClick={() => {
onChange?.('');
close();
}}
>
{calendarType === 'AMH' ? 'አጽዳ' : 'Clear'}
</Button>
<Button
variant="light"
size="xs"
onClick={() => {
onChange?.(formatWireValue(new Date(), dateFormat, withTime));
close();
}}
>
{calendarType === 'AMH' ? 'ዛሬ' : 'Today'}
</Button>
</Group>
</Popover.Dropdown>
</Popover>
);
}

View File

@@ -0,0 +1,116 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { Group, Select, Text, type ComboboxItem, type SelectProps } from '@mantine/core';
import { registerLocale, getNames, getName, getAlpha2Code } from 'i18n-iso-countries';
import {
registerLocale as registerNationalityLocale,
getName as getNationalityNameRaw,
} from 'i18n-nationality';
import * as Flags from 'country-flag-icons/react/3x2';
import en from 'i18n-iso-countries/langs/en.json';
import am from 'i18n-iso-countries/langs/am.json';
import nationalityEn from 'i18n-nationality/langs/en.json';
// Registered once at module load — locale data is static.
registerLocale(en);
registerLocale(am);
registerNationalityLocale(nationalityEn);
type CountryLang = 'en' | 'am';
function resolveLang(lng: string): CountryLang {
return lng === 'am' ? 'am' : 'en';
}
/** Localized country name for an alpha-2 code; '' when unset/unknown. */
export function getCountryName(code: string | null | undefined, lang: CountryLang = 'en'): string {
return code ? (getName(code, lang) ?? '') : '';
}
/** Alpha-2 code for a stored country name (e.g. "Ethiopian" data); undefined when unmatched. */
export function getCountryCode(name: string | null | undefined, lang: CountryLang = 'en'): string | undefined {
return name ? getAlpha2Code(name, lang) : undefined;
}
/**
* Localized demonym for an alpha-2 code (e.g. "ET" -> "Ethiopian"), for
* nationality fields as opposed to plain country fields.
* ponytail: i18n-nationality only ships an English locale — Amharic falls
* back to the country name until an Amharic demonym dataset shows up.
*/
export function getNationalityName(code: string | null | undefined, lang: CountryLang = 'en'): string {
if (!code) return '';
if (lang === 'en') return getNationalityNameRaw(code, 'en') ?? getCountryName(code, lang);
return getCountryName(code, lang);
}
function CountryFlag({ code }: { code: string }) {
const Flag = Flags[code as keyof typeof Flags];
return Flag ? <Flag style={{ width: 22, borderRadius: 2, display: 'block' }} /> : null;
}
// Case-insensitive substring on name, plus ISO code prefix ("et" → Ethiopia).
const filterCountries: SelectProps['filter'] = ({ options, search }) => {
const q = search.trim().toLowerCase();
if (!q) return options;
return (options as ComboboxItem[]).filter(
(o) => o.label.toLowerCase().includes(q) || o.value.toLowerCase().startsWith(q),
);
};
// Module scope → stable reference, no re-render churn.
const renderCountryOption: SelectProps['renderOption'] = ({ option }) => (
<Group gap="xs" wrap="nowrap">
<CountryFlag code={option.value} />
<Text fz="sm">{option.label}</Text>
</Group>
);
export interface CountrySelectProps {
value: string | null;
onChange: (value: string | null) => void;
label?: string;
placeholder?: string;
description?: React.ReactNode;
error?: React.ReactNode;
required?: boolean;
disabled?: boolean;
/** Show nationality labels ("Ethiopian") instead of country names ("Ethiopia"). */
demonym?: boolean;
}
export function CountrySelect({
value,
onChange,
placeholder,
demonym,
...rest
}: CountrySelectProps) {
const { t, i18n } = useTranslation();
const lang = resolveLang(i18n.language);
const countries = useMemo(
() =>
Object.keys(getNames(lang))
.map((code) => ({ value: code, label: demonym ? getNationalityName(code, lang) : getCountryName(code, lang) }))
.sort((a, b) => a.label.localeCompare(b.label, lang)),
[lang, demonym],
);
return (
<Select
data={countries}
value={value}
onChange={onChange}
placeholder={placeholder ?? t('country.select')}
leftSection={value ? <CountryFlag code={value} /> : undefined}
renderOption={renderCountryOption}
filter={filterCountries}
nothingFoundMessage={t('country.notFound')}
searchable
clearable
maxDropdownHeight={320}
{...rest}
/>
);
}

View File

@@ -0,0 +1,57 @@
import { Stack, Text, Group } from '@mantine/core';
import { IconCheck, IconX } from '@tabler/icons-react';
import { z } from 'zod';
export function passwordRules(minLength: number) {
return [
{ label: `At least ${minLength} characters`, test: (p: string) => p.length >= minLength },
{ label: 'One lowercase letter', test: (p: string) => /[a-z]/.test(p) },
{ label: 'One uppercase letter', test: (p: string) => /[A-Z]/.test(p) },
{ label: 'One number', test: (p: string) => /\d/.test(p) },
{ label: 'One special character', test: (p: string) => /[^A-Za-z0-9]/.test(p) },
];
}
/** Zod field schema enforcing every rule; unmet rules surface as separate issues. */
export const passwordSchema = (minLength: number) =>
z.string().superRefine((val, ctx) => {
for (const rule of passwordRules(minLength)) {
if (!rule.test(val)) {
ctx.addIssue({ code: 'custom', message: rule.label });
}
}
});
export const passwordMeetsAll = (password: string, minLength: number) =>
passwordRules(minLength).every((rule) => rule.test(password));
interface PasswordRequirementsProps {
password: string;
minLength: number;
labels?: string[];
}
/** Live checklist of password requirements, ticking off as the user types. Hidden until the user starts typing. */
export function PasswordRequirements({ password, minLength, labels }: PasswordRequirementsProps) {
if (!password) return null;
const rules = passwordRules(minLength);
return (
<Stack gap={6} mt={6}>
{rules.map((rule, i) => {
const met = rule.test(password);
return (
<Group key={rule.label} gap={6} wrap="nowrap">
{met ? (
<IconCheck size={14} color="var(--mantine-color-teal-6)" />
) : (
<IconX size={14} color="var(--mantine-color-gray-5)" />
)}
<Text fz="xs" c={met ? 'teal' : 'dimmed'}>
{labels?.[i] ?? rule.label}
</Text>
</Group>
);
})}
</Stack>
);
}

View File

@@ -0,0 +1,13 @@
import { z } from 'zod';
/** Accepts `09xxxxxxxx` or `+2519xxxxxxxx`; always yields `+2519xxxxxxxx`. */
export const ethiopianPhone = z
.string()
.trim()
.transform((v) => (/^09\d{8}$/.test(v) ? `+251${v.slice(1)}` : v))
.refine((v) => /^\+2519\d{8}$/.test(v), {
message: 'Enter a valid phone number (+2519xxxxxxxx)',
});
/** Same rules, but blank is allowed. */
export const optionalEthiopianPhone = z.union([z.literal(''), ethiopianPhone]).optional();

View File

@@ -34,6 +34,8 @@ interface AppHeaderProps {
userName?: string;
userInitials?: string;
supportedLanguages: readonly string[];
onNotificationsClick?: () => void;
notificationCount?: number;
}
export function AppHeader({
@@ -46,6 +48,8 @@ export function AppHeader({
userName = 'User',
userInitials = '?',
supportedLanguages,
onNotificationsClick,
notificationCount,
}: AppHeaderProps) {
const isMobile = typeof window !== 'undefined' && window.innerWidth < 768;
@@ -201,9 +205,17 @@ export function AppHeader({
e.currentTarget.style.boxShadow =
'0 1px 3px rgba(0,0,0,0.04), 0 0 0 1px rgba(0,0,0,0.06)';
}}
onClick={onNotificationsClick}
aria-label="Notifications"
>
<Indicator color="red" size={9} offset={5} withBorder>
<Indicator
color="red"
size={notificationCount ? 16 : 9}
offset={notificationCount ? 2 : 5}
withBorder
disabled={notificationCount === 0}
label={notificationCount || undefined}
>
<IconBell size={19} />
</Indicator>
</UnstyledButton>

View File

@@ -1,6 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import {
AppShell,
Badge,
Group,
NavLink,
Popover,
ScrollArea,
@@ -9,8 +11,10 @@ import {
Tooltip,
UnstyledButton,
rem,
useMantineColorScheme,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronLeft,
IconChevronRight,
} from '@tabler/icons-react';
@@ -64,6 +68,10 @@ interface SidebarItemProps {
collapsed: boolean;
activePath: string;
onNavigate: (item: NavItem) => void;
/** Whether this item's children are expanded. Ignored when it has none. */
opened: boolean;
/** Called with the new expanded state when the header is toggled. */
onToggle: (opened: boolean) => void;
}
/**
@@ -75,7 +83,7 @@ interface SidebarItemProps {
* nest, so a parent becomes a hover flyout instead of silently losing its
* children.
*/
function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemProps) {
function SidebarItem({ item, collapsed, activePath, onNavigate, opened, onToggle }: SidebarItemProps) {
const { t } = useTranslation();
const ItemIcon = item.icon;
const hasChildren = Boolean(item.children?.length);
@@ -101,10 +109,29 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
</Badge>
);
const rightSection = item.soon ? (
// Parent headers always carry a chevron so the expand/collapse state is
// never ambiguous, even when a badge is also present.
const chevron = hasChildren ? (
opened ? (
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
) : (
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
)
) : null;
const soonBadge = (
<Badge size="xs" variant="light" color="gray" radius="sm">
{t('nav.soon', 'Soon')}
</Badge>
);
const rightSection = hasChildren ? (
<Group gap={4} wrap="nowrap">
{item.soon ? soonBadge : badgeNode}
{chevron}
</Group>
) : item.soon ? (
soonBadge
) : (
badgeNode || undefined
);
@@ -183,8 +210,11 @@ function SidebarItem({ item, collapsed, activePath, onNavigate }: SidebarItemPro
label={t(item.label)}
leftSection={<ItemIcon size={19} stroke={1.6} />}
rightSection={rightSection}
// Auto-expands the group the user is currently inside.
defaultOpened={hasChildren ? branchActive : undefined}
disableRightSectionRotation
// Controlled so the group re-opens if the active route moves inside it
// later (see the auto-expand effect in AppSidebar), not just on mount.
opened={hasChildren ? opened : undefined}
onChange={hasChildren ? onToggle : undefined}
onClick={() => !hasChildren && onNavigate(item)}
variant="light"
styles={{
@@ -249,6 +279,55 @@ export function AppSidebar({
brandLogo,
}: AppSidebarProps) {
const { t } = useTranslation();
const { colorScheme } = useMantineColorScheme();
const hoverBg = colorScheme === 'dark'
? 'var(--mantine-color-dark-6)'
: 'var(--mantine-color-gray-0)';
const hoverColor = colorScheme === 'dark'
? 'var(--mantine-color-gray-3)'
: 'var(--mantine-color-gray-7)';
const sections = useMemo(() => toSections(navItems), [navItems]);
// Expand/collapse state per collapsible header, keyed by section label for
// NavSection headings and by item label for a parent item's children.
// Sections default open (today every section is always visible); a nested
// item's children default to open only when the active route is inside it,
// matching the previous `defaultOpened` behaviour.
const [openMap, setOpenMap] = useState<Record<string, boolean>>({});
// If the active route moves into a header the user had collapsed, expand
// it back open so the highlighted item stays visible. Never collapses
// anything — that stays purely a manual, per-header action.
useEffect(() => {
setOpenMap((prev) => {
let changed = false;
const next = { ...prev };
sections.forEach((section, sectionIndex) => {
const sectionKey = section.label ?? `section-${sectionIndex}`;
if (
section.label &&
next[sectionKey] !== true &&
section.items.some((item) => isBranchActive(item, activePath))
) {
next[sectionKey] = true;
changed = true;
}
section.items.forEach((item) => {
if (item.children?.length && next[item.label] !== true && isBranchActive(item, activePath)) {
next[item.label] = true;
changed = true;
}
});
});
return changed ? next : prev;
});
}, [activePath, sections]);
const toggleSection = (key: string) =>
setOpenMap((prev) => ({ ...prev, [key]: !(prev[key] ?? true) }));
const setItemOpened = (key: string, next: boolean) =>
setOpenMap((prev) => ({ ...prev, [key]: next }));
return (
<>
@@ -299,41 +378,64 @@ export function AppSidebar({
{/* Navigation items */}
<AppShell.Section grow component={ScrollArea} p="md">
<Stack gap={2}>
{toSections(navItems).map((section, sectionIndex) => (
<Stack gap={2} key={section.label ?? `section-${sectionIndex}`}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<Text
size="xs"
fw={700}
c="dimmed"
mt={sectionIndex === 0 ? 0 : rem(14)}
pl={rem(12)}
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{t(section.label)}
</Text>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
)}
{section.items.map((item) => (
<SidebarItem
key={item.label}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
/>
))}
</Stack>
))}
{sections.map((section, sectionIndex) => {
const sectionKey = section.label ?? `section-${sectionIndex}`;
const sectionOpened = openMap[sectionKey] ?? true;
return (
<Stack gap={2} key={sectionKey}>
{/* Headings are noise when only icons are visible. */}
{section.label && !collapsed && (
<UnstyledButton
onClick={() => toggleSection(sectionKey)}
aria-expanded={sectionOpened}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
width: '100%',
padding: `0 ${rem(12)}`,
marginTop: sectionIndex === 0 ? 0 : rem(14),
}}
>
<Text
size="xs"
fw={700}
c="dimmed"
style={{ textTransform: 'uppercase', letterSpacing: '0.06em' }}
>
{t(section.label)}
</Text>
{sectionOpened ? (
<IconChevronDown size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
) : (
<IconChevronRight size={14} stroke={1.8} color="var(--mantine-color-gray-5)" />
)}
</UnstyledButton>
)}
{section.label && collapsed && sectionIndex > 0 && (
<div
style={{
height: 1,
margin: `${rem(8)} ${rem(6)}`,
backgroundColor: 'var(--mantine-color-gray-2)',
}}
/>
)}
{(!section.label || sectionOpened || collapsed) &&
section.items.map((item) => (
<SidebarItem
key={item.label}
item={item}
collapsed={collapsed}
activePath={activePath}
onNavigate={onNavigate}
opened={openMap[item.label] ?? isBranchActive(item, activePath)}
onToggle={(next) => setItemOpened(item.label, next)}
/>
))}
</Stack>
);
})}
</Stack>
</AppShell.Section>
@@ -371,8 +473,8 @@ export function AppSidebar({
fontWeight: 500,
}}
onMouseEnter={(e) => {
e.currentTarget.style.background = 'var(--mantine-color-gray-0)';
e.currentTarget.style.color = 'var(--mantine-color-gray-7)';
e.currentTarget.style.background = hoverBg;
e.currentTarget.style.color = hoverColor;
}}
onMouseLeave={(e) => {
e.currentTarget.style.background = 'transparent';

View File

@@ -1,261 +0,0 @@
import type { CSSProperties, ReactNode } from 'react';
import {
ActionIcon,
Box,
Checkbox,
Group,
LoadingOverlay,
Pagination,
Table,
Tooltip,
UnstyledButton,
type MantineSpacing,
} from '@mantine/core';
import {
IconChevronDown,
IconChevronUp,
IconRefresh,
IconSelector,
} from '@tabler/icons-react';
import { EmptyState } from '../feedback/EmptyState';
export interface AdvancedTableColumn<T> {
/** Unique column id; doubles as the sort field sent to `sort.onSort`. */
key: string;
header: ReactNode;
/** Cell renderer. Defaults to reading `row[key]`. */
render?: (row: T) => ReactNode;
sortable?: boolean;
width?: number | string;
align?: 'left' | 'center' | 'right';
}
export interface AdvancedTableAction<T> {
key: string;
/** Shown as tooltip and aria-label. */
label: string;
icon: ReactNode;
color?: string;
hidden?: (row: T) => boolean;
disabled?: (row: T) => boolean;
onClick: (row: T) => void;
}
export interface AdvancedTableSort {
sortBy?: string;
sortDir?: 'asc' | 'desc';
onSort: (field: string) => void;
}
export interface AdvancedTablePagination {
page: number;
totalPages: number;
onPageChange: (page: number) => void;
}
export interface AdvancedTableSelection {
selected: string[];
onChange: (ids: string[]) => void;
}
export interface AdvancedTableProps<T> {
columns: AdvancedTableColumn<T>[];
data: T[];
rowKey: (row: T) => string;
actions?: AdvancedTableAction<T>[];
sort?: AdvancedTableSort;
pagination?: AdvancedTablePagination;
/** Controlled row selection (checkbox column); ids come from `rowKey`. */
selection?: AdvancedTableSelection;
loading?: boolean;
/** Min table width before horizontal scroll kicks in. */
minWidth?: number;
/** Row density, e.g. 4 (compact) or 'sm' (comfortable). */
verticalSpacing?: MantineSpacing;
/** Per-row style override (e.g. focused-row highlight). */
rowStyle?: (row: T) => CSSProperties | undefined;
/** Rendered above the table, left-aligned (filters, search, tabs…). */
toolbar?: ReactNode;
/** Shows a refresh button above the table, right-aligned. */
onRefresh?: () => void;
emptyTitle?: string;
emptyDescription?: string;
onRowClick?: (row: T) => void;
}
function SortableHeader({
column,
sort,
}: {
column: AdvancedTableColumn<never>;
sort: AdvancedTableSort;
}) {
const active = sort.sortBy === column.key;
const Icon = active
? sort.sortDir === 'desc'
? IconChevronDown
: IconChevronUp
: IconSelector;
return (
<UnstyledButton onClick={() => sort.onSort(column.key)} fz="sm" fw={700}>
<Group gap={4} wrap="nowrap">
{column.header}
<Icon size={14} stroke={1.5} />
</Group>
</UnstyledButton>
);
}
/**
* Generic data table: column definitions and row actions come from the
* consumer as config (typically `*Columns.tsx` + `*ColumnActions.tsx` files);
* filters, search and refresh live in the parent component above the table.
* Sorting and pagination are controlled — the parent owns the state (URL,
* query params) and refetches.
*/
export function AdvancedTable<T>({
columns,
data,
rowKey,
actions,
sort,
pagination,
selection,
loading = false,
minWidth = 640,
verticalSpacing,
rowStyle,
toolbar,
onRefresh,
emptyTitle = 'Nothing here yet',
emptyDescription,
onRowClick,
}: AdvancedTableProps<T>) {
const allIds = data.map(rowKey);
const allSelected = selection
? allIds.length > 0 && allIds.every((id) => selection.selected.includes(id))
: false;
const toolbarRow = (toolbar || onRefresh) && (
<Group justify="space-between" mb="sm" wrap="nowrap" align="flex-end">
<Box style={{ flex: 1 }}>{toolbar}</Box>
{onRefresh && (
<Tooltip label="Refresh">
<ActionIcon variant="subtle" color="gray" onClick={onRefresh} aria-label="Refresh">
<IconRefresh size={18} />
</ActionIcon>
</Tooltip>
)}
</Group>
);
if (!loading && data.length === 0) {
return (
<Box>
{toolbarRow}
<EmptyState title={emptyTitle} description={emptyDescription} />
</Box>
);
}
return (
<Box pos="relative">
{toolbarRow}
<LoadingOverlay visible={loading} zIndex={10} />
<Table.ScrollContainer minWidth={minWidth}>
<Table striped highlightOnHover verticalSpacing={verticalSpacing}>
<Table.Thead>
<Table.Tr>
{selection && (
<Table.Th w={40}>
<Checkbox
checked={allSelected}
indeterminate={selection.selected.length > 0 && !allSelected}
onChange={() => selection.onChange(allSelected ? [] : allIds)}
aria-label="Select all"
/>
</Table.Th>
)}
{columns.map((col) => (
<Table.Th key={col.key} w={col.width} ta={col.align}>
{col.sortable && sort ? (
<SortableHeader column={col as AdvancedTableColumn<never>} sort={sort} />
) : (
col.header
)}
</Table.Th>
))}
{actions && <Table.Th w={1} />}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.map((row) => (
<Table.Tr
key={rowKey(row)}
onClick={onRowClick ? () => onRowClick(row) : undefined}
style={{
...(onRowClick ? { cursor: 'pointer' } : undefined),
...rowStyle?.(row),
}}
>
{selection && (
<Table.Td onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={selection.selected.includes(rowKey(row))}
onChange={(e) => {
const id = rowKey(row);
selection.onChange(
e.currentTarget.checked
? [...selection.selected, id]
: selection.selected.filter((s) => s !== id),
);
}}
aria-label="Select row"
/>
</Table.Td>
)}
{columns.map((col) => (
<Table.Td key={col.key} ta={col.align}>
{col.render
? col.render(row)
: ((row as Record<string, unknown>)[col.key] as ReactNode)}
</Table.Td>
))}
{actions && (
<Table.Td onClick={(e) => e.stopPropagation()}>
<Group gap="xs" wrap="nowrap" justify="flex-end">
{actions
.filter((a) => !a.hidden?.(row))
.map((a) => (
<Tooltip key={a.key} label={a.label}>
<ActionIcon
variant="subtle"
color={a.color}
disabled={a.disabled?.(row)}
aria-label={a.label}
onClick={() => a.onClick(row)}
>
{a.icon}
</ActionIcon>
</Tooltip>
))}
</Group>
</Table.Td>
)}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Table.ScrollContainer>
{pagination && pagination.totalPages > 1 && (
<Group justify="flex-end" mt="md">
<Pagination
value={pagination.page}
total={pagination.totalPages}
onChange={pagination.onPageChange}
/>
</Group>
)}
</Box>
);
}

View File

@@ -1,5 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "outDir": "../../dist/out-tsc" },
"compilerOptions": { "outDir": "../../dist/out-tsc", "types": ["vite/client"] },
"include": ["src/**/*.ts", "src/**/*.tsx"]
}