mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
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:
@@ -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: () => ({}),
|
||||
});
|
||||
|
||||
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
1
libs/api/src/lib/base-api/tagTypes.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const tagTypes = ["ProfessionApi"];
|
||||
@@ -1,3 +1,4 @@
|
||||
export * from './licensing.types';
|
||||
export * from './licensing-api';
|
||||
export * from './licensing.helpers';
|
||||
export * from './use-localized';
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal file
27
libs/api/src/lib/features/licensing/use-localized.ts
Normal 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]);
|
||||
}
|
||||
105
libs/api/src/lib/file-upload/index.ts
Normal file
105
libs/api/src/lib/file-upload/index.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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 } }): {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 />;
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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't have an account?{' '}
|
||||
Don't have an account?{" "}
|
||||
<Anchor component={Link} to="/signup" fw={700}>
|
||||
Create one
|
||||
</Anchor>
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -13,6 +13,7 @@ export interface AuthUser {
|
||||
hasFinishedRegistration: boolean;
|
||||
hasFinishedDMSOnboarding: boolean;
|
||||
isPhoneNumberVerified: boolean;
|
||||
userType: string;
|
||||
}
|
||||
|
||||
export interface AuthState {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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';
|
||||
|
||||
57
libs/shared/src/lib/date/date-displayer.ts
Normal file
57
libs/shared/src/lib/date/date-displayer.ts
Normal 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}`;
|
||||
}
|
||||
69
libs/shared/src/lib/date/ethiopic.ts
Normal file
69
libs/shared/src/lib/date/ethiopic.ts
Normal 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:00–11:59 preimage.
|
||||
export const ETH_PERIODS: { value: EthPeriod; label: string; hours: number[] }[] = [
|
||||
{ value: 'tewat', label: 'ጠዋት', hours: [12, 1, 2, 3, 4, 5] }, // 06:00–11:59
|
||||
{ value: 'ken', label: 'ቀን', hours: [6, 7, 8, 9, 10, 11] }, // 12:00–17:59
|
||||
{ value: 'mata', label: 'ማታ', hours: [12, 1, 2, 3, 4, 5] }, // 18:00–23:59
|
||||
{ value: 'lelit', label: 'ሌሊት', hours: [6, 7, 8, 9, 10, 11] }, // 00:00–05: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}`;
|
||||
}
|
||||
22
libs/shared/src/lib/date/use-date-displayer.ts
Normal file
22
libs/shared/src/lib/date/use-date-displayer.ts
Normal 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]);
|
||||
}
|
||||
@@ -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%)',
|
||||
},
|
||||
|
||||
14
libs/shared/vitest.config.ts
Normal file
14
libs/shared/vitest.config.ts
Normal 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'],
|
||||
},
|
||||
});
|
||||
@@ -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";
|
||||
|
||||
436
libs/ui/src/lib/components/MaritimeLoader.tsx
Normal file
436
libs/ui/src/lib/components/MaritimeLoader.tsx
Normal 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>
|
||||
158
libs/ui/src/lib/data/AdvancedTable.md
Normal file
158
libs/ui/src/lib/data/AdvancedTable.md
Normal 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`).
|
||||
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal file
259
libs/ui/src/lib/data/AdvancedTable.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
53
libs/ui/src/lib/data/useServerTable.ts
Normal file
53
libs/ui/src/lib/data/useServerTable.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
38
libs/ui/src/lib/feedback/ModalFooter.tsx
Normal file
38
libs/ui/src/lib/feedback/ModalFooter.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
72
libs/ui/src/lib/feedback/use-error-handler.ts
Normal file
72
libs/ui/src/lib/feedback/use-error-handler.ts
Normal 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 };
|
||||
}
|
||||
224
libs/ui/src/lib/input/AmharicDatePicker.css
Normal file
224
libs/ui/src/lib/input/AmharicDatePicker.css
Normal 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;
|
||||
}
|
||||
403
libs/ui/src/lib/input/AmharicDatePicker.tsx
Normal file
403
libs/ui/src/lib/input/AmharicDatePicker.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
116
libs/ui/src/lib/input/CountrySelect.tsx
Normal file
116
libs/ui/src/lib/input/CountrySelect.tsx
Normal 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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
57
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal file
57
libs/ui/src/lib/input/PasswordRequirements.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
13
libs/ui/src/lib/input/phone.ts
Normal file
13
libs/ui/src/lib/input/phone.ts
Normal 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();
|
||||
@@ -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>
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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"]
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user