user management ui

This commit is contained in:
yaschalew
2026-07-10 10:41:48 +03:00
parent dcb2d98503
commit 28a20923ff
595 changed files with 0 additions and 0 deletions

View File

@@ -0,0 +1,49 @@
import Cookies from "js-cookie";
type CookieOptions = NonNullable<Parameters<typeof Cookies.set>[2]>;
const REMEMBER_ME_STORAGE_KEY = "rememberMe";
const parseRememberMe = (value: string | null) => {
if (!value) return false;
try {
return JSON.parse(value) === true;
} catch {
return false;
}
};
export const getRememberMePreference = () =>
parseRememberMe(localStorage.getItem(REMEMBER_ME_STORAGE_KEY));
export const persistRememberMePreference = (rememberMe: boolean) => {
localStorage.setItem(REMEMBER_ME_STORAGE_KEY, JSON.stringify(rememberMe));
};
export const clearRememberMePreference = () => {
localStorage.removeItem(REMEMBER_ME_STORAGE_KEY);
};
export const getAuthCookieOptions = (
rememberMe: boolean,
): CookieOptions => ({
...(rememberMe ? { expires: 7 } : {}),
secure: window.location.protocol === "https:",
sameSite: "Strict",
});
export const setAuthCookies = ({
token,
refreshToken,
rememberMe,
}: {
token: string;
refreshToken: string;
rememberMe: boolean;
}) => {
const cookieOptions = getAuthCookieOptions(rememberMe);
Cookies.set("auth-token", token, cookieOptions);
Cookies.set("refresh-token", refreshToken, cookieOptions);
};

View File

@@ -0,0 +1,172 @@
/**
* Shared utilities for exporting dashboard/report charts via html2canvas.
*
* Problem: html2canvas serializes SVG elements to a data URL before drawing
* them on canvas. Inside that data URL the parent document's CSS is not
* available, so `fill="var(--primary)"` renders as nothing / black.
* Additionally, html2canvas's internal CSS parser cannot handle oklch() color
* values used by Tailwind v4.
*
* Solution:
* 1. Patch oklch() in <style> tags and inject :root overrides so html2canvas's
* CSS parser sees rgb() values everywhere.
* 2. Resolve CSS variable references in SVG presentation attributes
* (fill, stroke, stop-color) to their computed rgb() values before capture,
* then restore them afterward.
*/
// ── Oklch → RGB conversion ────────────────────────────────────────────────────
export function oklchToRgb(l: number, c: number, h: number): [number, number, number] {
const hr = (h * Math.PI) / 180;
const a = c * Math.cos(hr);
const b = c * Math.sin(hr);
const l_ = l + 0.3963377774 * a + 0.2158037573 * b;
const m_ = l - 0.1055613458 * a - 0.0638541728 * b;
const s_ = l - 0.0894841775 * a - 1.2914855480 * b;
const lc = l_ ** 3, mc = m_ ** 3, sc = s_ ** 3;
const gam = (x: number) =>
x <= 0.0031308 ? 12.92 * x : 1.055 * x ** (1 / 2.4) - 0.055;
const clamp = (x: number) => Math.max(0, Math.min(255, Math.round(x * 255)));
return [
clamp(gam(+4.0767416621 * lc - 3.3077115913 * mc + 0.2309699292 * sc)),
clamp(gam(-1.2684380046 * lc + 2.6097574011 * mc - 0.3413193965 * sc)),
clamp(gam(-0.0041960863 * lc - 0.7034186147 * mc + 1.7076147010 * sc)),
];
}
export const OKLCH_RE = /oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)[^)]*\)/g;
export function oklchMatchToRgb(_: string, l: string, c: string, h: string): string {
const lv = l.endsWith("%") ? parseFloat(l) / 100 : parseFloat(l);
const [r, g, b] = oklchToRgb(lv, parseFloat(c), parseFloat(h));
return `rgb(${r},${g},${b})`;
}
// ── CSS oklch patching ────────────────────────────────────────────────────────
function collectOklchOverrides(
rules: CSSRuleList,
overrides: Map<string, string>,
): void {
for (const rule of Array.from(rules)) {
if (rule instanceof CSSStyleRule) {
for (let i = 0; i < rule.style.length; i++) {
const prop = rule.style[i];
const val = rule.style.getPropertyValue(prop).trim();
if (!val.includes("oklch") || overrides.has(prop)) continue;
const m = val.match(/oklch\(\s*([\d.]+%?)\s+([\d.]+)\s+([\d.]+)/);
if (m) {
const lv = m[1].endsWith("%") ? parseFloat(m[1]) / 100 : parseFloat(m[1]);
const [r, g, b] = oklchToRgb(lv, parseFloat(m[2]), parseFloat(m[3]));
overrides.set(prop, `rgb(${r},${g},${b})`);
}
}
}
if ("cssRules" in rule && (rule as CSSGroupingRule).cssRules) {
collectOklchOverrides((rule as CSSGroupingRule).cssRules, overrides);
}
}
}
/**
* Patches oklch() values in inline <style> tags and injects a :root override
* block for external stylesheets so html2canvas's CSS parser sees only rgb().
* Returns a `restore()` function that undoes all patches.
*/
export function patchDocumentOklch(): { restore: () => void } {
const patched = new Map<HTMLStyleElement, string>();
for (const el of Array.from(document.querySelectorAll("style"))) {
const s = el as HTMLStyleElement;
if (s.textContent?.includes("oklch")) {
patched.set(s, s.textContent);
s.textContent = s.textContent.replace(OKLCH_RE, oklchMatchToRgb);
}
}
const overrideVars = new Map<string, string>();
for (const sheet of Array.from(document.styleSheets)) {
try { collectOklchOverrides(sheet.cssRules, overrideVars); } catch {}
}
let varOverride: HTMLStyleElement | null = null;
if (overrideVars.size > 0) {
varOverride = document.createElement("style");
varOverride.textContent = `:root { ${
Array.from(overrideVars.entries())
.map(([p, v]) => `${p}: ${v}`)
.join("; ")
} }`;
document.head.appendChild(varOverride);
}
return {
restore() {
for (const [s, orig] of patched.entries()) s.textContent = orig;
varOverride?.remove();
},
};
}
// ── SVG presentation attribute inlining ──────────────────────────────────────
const SVG_PRES_ATTRS = ["fill", "stroke", "stop-color"] as const;
/**
* Walks every SVG descendant of `root` and replaces any presentation attribute
* that contains a CSS variable (`var(…)`) with its computed rgb() value.
*
* This must be called AFTER patchDocumentOklch() so that getComputedStyle
* already returns rgb values rather than oklch.
*
* Returns a `restore()` function that reverts all attribute changes.
* For off-screen clones that are discarded after capture, calling restore()
* is optional.
*/
export function inlineSvgPresentationAttrs(
root: Element,
): { restore: () => void } {
const patches: Array<{ el: SVGElement; attr: string; orig: string }> = [];
for (const node of Array.from(root.querySelectorAll("*"))) {
if (!(node instanceof SVGElement)) continue;
const cs = window.getComputedStyle(node);
for (const attr of SVG_PRES_ATTRS) {
const val = node.getAttribute(attr);
if (!val || !val.includes("var(")) continue;
const resolved = cs.getPropertyValue(attr).trim().replace(OKLCH_RE, oklchMatchToRgb);
if (!resolved || resolved === val) continue;
patches.push({ el: node, attr, orig: val });
node.setAttribute(attr, resolved);
}
}
return {
restore() {
for (const { el, attr, orig } of patches) el.setAttribute(attr, orig);
},
};
}
/**
* Convenience wrapper that runs the full pre-capture patch sequence on a DOM
* element:
* 1. patchDocumentOklch fixes oklch in <style>/<link> CSS
* 2. inlineSvgPresentationAttrs resolves var() in SVG fill/stroke attrs
*
* Returns a single `restore()` that undoes both steps in reverse order.
*
* Usage:
* const patch = prepareForHtml2Canvas(element);
* try { await html2canvas(element, …); } finally { patch.restore(); }
*/
export function prepareForHtml2Canvas(root: Element): { restore: () => void } {
const oklch = patchDocumentOklch();
const svg = inlineSvgPresentationAttrs(root);
return {
restore() {
svg.restore();
oklch.restore();
},
};
}

View File

@@ -0,0 +1,32 @@
import { VisibilityState } from "@tanstack/react-table";
const STORAGE_KEY_PREFIX = "table-column-visibility:";
const getStorageKey = (tableName: string) =>
`${STORAGE_KEY_PREFIX}${tableName}`;
/**
* Reads/writes column visibility per table. Backed by localStorage for now;
* swap the implementations here for backend calls later without touching callers.
*/
export const getColumnVisibility = (
tableName: string,
): VisibilityState | null => {
try {
const raw = localStorage.getItem(getStorageKey(tableName));
return raw ? (JSON.parse(raw) as VisibilityState) : null;
} catch {
return null;
}
};
export const setColumnVisibility = (
tableName: string,
visibility: VisibilityState,
): void => {
try {
localStorage.setItem(getStorageKey(tableName), JSON.stringify(visibility));
} catch {
// ignore write errors (e.g. storage unavailable)
}
};

View File

@@ -0,0 +1,148 @@
import Cookies from "js-cookie";
import { MeDto } from "@/shared/dto/user/meDto";
import {
getProfile,
RegisterWithFaydaResponse,
} from "@/shared/services/authService";
import {
getAuthCookieOptions,
setAuthCookies,
} from "@/shared/utils/authPersistence";
import type { VerifiedCitizen } from "@/complaints/types/complaint.types";
function unwrapApiData<T>(payload: T | { data?: T }): T {
if (
payload &&
typeof payload === "object" &&
"data" in payload &&
payload.data &&
typeof payload.data === "object"
) {
return payload.data;
}
return payload as T;
}
function resolveLocalizedName(
name: string | { am?: string; en?: string } | undefined,
preferredLanguage = "en",
): string {
if (!name) return "";
if (typeof name === "string") return name.trim();
if (preferredLanguage === "am") {
return (name.am || name.en || "").trim();
}
return (name.en || name.am || "").trim();
}
function resolveProfileDisplayName(
profile: MeDto | null | undefined,
preferredLanguage = "en",
): string {
if (!profile) return "";
const extendedProfile = profile as MeDto & {
fullName?: string;
firstName?: string;
lastName?: string;
faydaId?: string;
};
const localizedName = resolveLocalizedName(profile.name, preferredLanguage);
if (localizedName) return localizedName;
const combinedName = [extendedProfile.firstName, extendedProfile.lastName]
.filter(Boolean)
.join(" ")
.trim();
if (combinedName) return combinedName;
if (extendedProfile.fullName?.trim()) {
return extendedProfile.fullName.trim();
}
if (profile.username?.trim()) return profile.username.trim();
if (profile.email?.trim()) return profile.email.trim();
if (profile.phoneNumber?.trim()) return profile.phoneNumber.trim();
return "";
}
export function mapRegisterWithFaydaCitizen(
data: RegisterWithFaydaResponse,
preferredLanguage = "en",
profile?: MeDto | null,
): VerifiedCitizen {
const fullName =
String(
data.citizen?.fullName ??
data.fullName ??
data.user?.fullName ??
resolveLocalizedName(data.user?.name, preferredLanguage) ??
resolveProfileDisplayName(profile, preferredLanguage) ??
"",
).trim();
const faydaId = String(
data.citizen?.faydaId ??
data.faydaId ??
data.user?.faydaId ??
(profile as { faydaId?: string } | undefined)?.faydaId ??
profile?.username ??
"",
)
.trim()
.replace(/\s+/g, "");
return { fullName, faydaId };
}
export async function persistFaydaRegistrationAuth(
data: RegisterWithFaydaResponse,
): Promise<MeDto | null> {
const token = data.token?.trim();
const refreshToken = data.refreshToken?.trim();
if (!token) {
return null;
}
const cookieOptions = getAuthCookieOptions(false);
setAuthCookies({
token,
refreshToken: refreshToken ?? "",
rememberMe: false,
});
try {
const { data: profile } = await getProfile({
headers: {
Authorization: `Bearer ${token}`,
},
});
const userDetails = unwrapApiData(profile as MeDto | { data?: MeDto });
Cookies.set(
"auth-user",
JSON.stringify({
...userDetails,
id: userDetails.id,
name: userDetails.name,
permissions: userDetails.permissions,
roles: userDetails.roles,
}),
cookieOptions,
);
const firstPositionId =
userDetails.employee?.[0]?.positions?.[0]?.employeePositionId;
if (firstPositionId) {
Cookies.set("current-position-id", firstPositionId, cookieOptions);
}
return userDetails;
} catch {
return null;
}
}

View File

@@ -0,0 +1,125 @@
export interface FaydaOidcOptions {
redirectUri?: string;
state?: string;
nonce?: string;
}
/** PKCE code_verifier matching DEFAULT_CODE_CHALLENGE (S256). */
export const FAYDA_CODE_VERIFIER =
"dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk";
const DEFAULT_CODE_CHALLENGE = "E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM";
const DEFAULT_NONCE = "g4DEuje5Fx57Vb64dO4oqLHXGT8L8G7g";
const DEFAULT_STATE = "ptOO76SD";
/** OIDC state value that routes the shared /callback to the complaint flow (legacy sign-in). */
export const COMPLAINT_FLOW_STATE = "complaint_flow";
/** Complaint flow OIDC states — distinguish sign-in vs sign-up endpoints. */
export const COMPLAINT_SIGNIN_STATE = "complaint_signin";
export const COMPLAINT_SIGNUP_STATE = "complaint_signup";
export function isComplaintFaydaState(state: string | null): boolean {
return (
state === COMPLAINT_FLOW_STATE ||
state === COMPLAINT_SIGNIN_STATE ||
state === COMPLAINT_SIGNUP_STATE
);
}
export function resolveComplaintAuthMode(
state: string | null,
): "signin" | "signup" {
if (state === COMPLAINT_SIGNUP_STATE) {
return "signup";
}
return "signin";
}
/** OIDC state for unified external-portal Fayda auth (sign-in or sign-up). */
export const EXTERNAL_PORTAL_CONTINUE_STATE = "external_continue";
/** @deprecated Legacy sign-in state — still accepted on callback. */
export const EXTERNAL_PORTAL_SIGNIN_STATE = "external_signin";
/** @deprecated Legacy sign-up state — still accepted on callback. */
export const EXTERNAL_PORTAL_SIGNUP_STATE = "external_signup";
export function isExternalPortalFaydaState(state: string | null): boolean {
return (
state === EXTERNAL_PORTAL_CONTINUE_STATE ||
state === EXTERNAL_PORTAL_SIGNIN_STATE ||
state === EXTERNAL_PORTAL_SIGNUP_STATE
);
}
export function startExternalPortalFaydaAuth(): void {
const authUrl = generateFaydaAuthorizationUrl({
state: EXTERNAL_PORTAL_CONTINUE_STATE,
});
window.location.href = authUrl;
}
/**
* Builds the FAYDA/MOSIP OIDC authorization URL.
* Shared by external-portal registration and anonymous complaint flows.
*/
export function generateFaydaAuthorizationUrl(
options: FaydaOidcOptions = {},
): string {
const redirectUri =
options.redirectUri ||
getDefaultFaydaRedirectUri();
const params = new URLSearchParams({
client_id: import.meta.env.VITE_CLIENT_ID || "",
redirect_uri: redirectUri,
response_type: "code",
scope: "openid profile email",
acr_values:
"mosip:idp:acr:generated-code mosip:idp:acr:linked-wallet mosip:idp:acr:biometrics",
claims:
'{"userinfo":{"individual_id":{"essential":true},"name":{"essential":true},"phone_number":{"essential":true},"email":{"essential":true},"picture":{"essential":true},"gender":{"essential":true},"birthdate":{"essential":true},"address":{"essential":true}},"id_token":{}}',
claims_locales: "en am",
code_challenge: DEFAULT_CODE_CHALLENGE,
code_challenge_method: "S256",
display: "page",
nonce: options.nonce || DEFAULT_NONCE,
state: options.state || DEFAULT_STATE,
ui_locales: "en am",
});
const endpoint = import.meta.env.VITE_AUTHORIZATION_ENDPOINT || "";
return `${endpoint}?${params.toString()}`;
}
/**
* Returns the default redirect URI registered with FAYDA (external portal flow).
*/
export function getDefaultFaydaRedirectUri(): string {
return (
import.meta.env.VITE_REDIRECT_URI ||
`${window.location.origin}/callback`
);
}
/**
* Returns the redirect URI registered with FAYDA for the complaint flow.
*
* Must exactly match a URI whitelisted in the FAYDA OIDC client — we reuse
* the same /callback path as external-portal registration and distinguish
* flows via the `state` parameter (see COMPLAINT_FLOW_STATE).
*/
export function getComplaintFaydaRedirectUri(): string {
return (
import.meta.env.VITE_COMPLAINT_REDIRECT_URI ||
getDefaultFaydaRedirectUri()
);
}
export function startComplaintFaydaAuth(): void {
const authUrl = generateFaydaAuthorizationUrl({
redirectUri: getComplaintFaydaRedirectUri(),
state: COMPLAINT_FLOW_STATE,
});
window.location.href = authUrl;
}

View File

@@ -0,0 +1,11 @@
export type FilterValue = string | number | boolean | string[] | undefined | null;
export type FilterParams = Record<string, FilterValue>;
export const cleanFilterParams = <T extends object>(params: T): Partial<T> =>
Object.fromEntries(
Object.entries(params).filter(([, value]) => {
if (value === null || value === undefined || value === "") return false;
if (Array.isArray(value) && value.length === 0) return false;
return true;
}),
) as Partial<T>;

View File

@@ -0,0 +1,86 @@
// src/shared/utils/get-final-allowed-file-types.ts
const extensionToMimeTypes: Record<string, string[]> = {
png: ["image/png"],
jpg: ["image/jpeg"],
jpeg: ["image/jpeg"],
gif: ["image/gif"],
pdf: ["application/pdf"],
dwg: ["application/acad", "application/x-autocad", "application/x-dwg", "image/vnd.dwg"],
dxf: ["application/dxf", "application/x-dxf", "image/vnd.dxf"],
dwt: ["application/x-autocad-dwt", "application/x-autocad"],
bak: ["application/octet-stream"],
"sv$": ["application/octet-stream"],
dws: ["application/x-autocad-dws", "application/x-autocad"],
mxd: ["application/x-esri-map", "application/octet-stream"],
aprx: ["application/x-esri-arcgis-pro-project", "application/octet-stream"],
rar: ["application/x-rar-compressed", "application/vnd.rar"],
zip: ["application/zip"],
};
const mimeToExtensions: Record<string, string[]> = Object.entries(
extensionToMimeTypes
).reduce((acc, [ext, mimes]) => {
for (const mime of mimes) {
if (!acc[mime]) acc[mime] = [];
acc[mime].push(`.${ext}`);
}
return acc;
}, {} as Record<string, string[]>);
const normalizeToken = (token: string) => token.trim().toLowerCase();
export const getAllowedMimeTypesFromAccept = (
accept: string[] | null | undefined
): string[] => {
if (!accept || accept.length === 0) return [];
const mimeSet = new Set<string>();
for (const rawToken of accept) {
const token = normalizeToken(rawToken);
if (!token) continue;
if (token.includes("/")) {
mimeSet.add(token);
continue;
}
const extension = token.startsWith(".") ? token.slice(1) : token;
(extensionToMimeTypes[extension] || []).forEach((mime) => mimeSet.add(mime));
}
return Array.from(mimeSet);
};
export const getAllowedExtensionsFromAccept = (
accept: string[] | null | undefined
): string[] => {
if (!accept || accept.length === 0) return [];
const extensionSet = new Set<string>();
for (const rawToken of accept) {
const token = normalizeToken(rawToken);
if (!token) continue;
if (token.includes("/")) {
(mimeToExtensions[token] || []).forEach((ext) => extensionSet.add(ext));
continue;
}
const normalizedExtension = token.startsWith(".") ? token : `.${token}`;
extensionSet.add(normalizedExtension);
}
return Array.from(extensionSet);
};
export const getFinalAllowedFileTypes = (
accept: string[] | null | undefined
): string => {
if (!accept || accept.length === 0) return "";
const result = accept.map((token) => normalizeToken(token)).join(",");
return result;
};

View File

@@ -0,0 +1,263 @@
import { EthDateTime } from "ethiopian-calendar-date-converter";
export interface HabeshaDate {
year: number;
month: number;
day: number;
monthName: string;
dayName: string;
formatted: string;
}
export class HabeshaCalendarUtils {
// Ethiopian month names in Amharic
static readonly MONTH_NAMES_AM = [
"መስከረም",
"ጥቅምት",
"ኅዳር",
"ታኅሣሥ",
"ጥር",
"የካቲት",
"መጋቢት",
"ሚያዝያ",
"ግንቦት",
"ሰኔ",
"ሐምሌ",
"ነሃሴ",
"ጳጉሜ",
];
// Ethiopian month names in English
static readonly MONTH_NAMES_EN = [
"Meskerem",
"Tikimt",
"Hidar",
"Tahsas",
"Tir",
"Yekatit",
"Megabit",
"Miazia",
"Ginbot",
"Sene",
"Hamle",
"Nehase",
"Pagume",
];
// Day names in Amharic
static readonly DAY_NAMES_AM = [
"እሁድ",
"ሰኞ",
"ማክሰኞ",
"ረቡዕ",
"ሐሙስ",
"ዓርብ",
"ቅዳሜ",
];
// Day names in English
static readonly DAY_NAMES_EN = [
"Sunday",
"Monday",
"Tuesday",
"Wednesday",
"Thursday",
"Friday",
"Saturday",
];
/**
* Convert Gregorian date to Habesha date
*/
static gregorianToHabesha(
gregorianDate: Date | string | null | undefined
): HabeshaDate {
// Handle null/undefined cases
if (!gregorianDate) {
gregorianDate = new Date();
}
// Convert string to Date if needed
if (typeof gregorianDate === "string") {
gregorianDate = new Date(gregorianDate);
}
// Validate that we have a valid Date object
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
gregorianDate = new Date();
}
const ethDateTime = EthDateTime.fromEuropeanDate(gregorianDate);
const dayOfWeek = gregorianDate.getDay();
return {
year: ethDateTime.year,
month: ethDateTime.month,
day: ethDateTime.date,
monthName: this.MONTH_NAMES_AM[ethDateTime.month - 1],
dayName: this.DAY_NAMES_AM[dayOfWeek],
formatted: this.formatHabeshaDate(ethDateTime, dayOfWeek),
};
}
/**
* Convert Habesha date to Gregorian date
*/
static habeshaToGregorian(year: number, month: number, day: number): Date {
const ethDateTime = new EthDateTime(year, month, day);
return ethDateTime.toEuropeanDate();
}
/**
* Format Habesha date as string
*/
static formatHabeshaDate(
ethDateTime: { year: number; month: number; date: number },
dayOfWeek: number,
includeDay: boolean = true
): string {
const dayName = includeDay ? `${this.DAY_NAMES_AM[dayOfWeek]} ` : "";
const monthName = this.MONTH_NAMES_AM[ethDateTime.month - 1];
return `${dayName}${monthName} ${ethDateTime.date}, ${ethDateTime.year}`;
}
/**
* Get current Habesha date
*/
static getCurrentHabeshaDate(): HabeshaDate {
return this.gregorianToHabesha(new Date());
}
/**
* Generate year options for dropdowns (Habesha years)
*/
static generateHabeshaYearOptions(
yearsBack: number = 5,
yearsAhead: number = 5
): Array<{ label: string; value: string }> {
const currentHabesha = this.getCurrentHabeshaDate();
const startYear = currentHabesha.year - yearsBack;
const endYear = currentHabesha.year + yearsAhead;
const years = [];
for (let year = startYear; year <= endYear; year++) {
years.push({
label: year.toString(),
value: year.toString(),
});
}
return years.reverse(); // Most recent first
}
/**
* Generate month options for dropdowns
*/
static getMonthOptions(
language: "am" | "en" = "am"
): Array<{ label: string; value: string }> {
const monthNames =
language === "am" ? this.MONTH_NAMES_AM : this.MONTH_NAMES_EN;
return monthNames.map((name, index) => ({
label: name,
value: (index + 1).toString(),
}));
}
/**
* Get days in a Habesha month
*/
static getDaysInHabeshaMonth(year: number, month: number): number {
// Most months have 30 days, Pagume (13th month) has 5 or 6 days
if (month === 13) {
// Leap year calculation for Ethiopian calendar
return this.isHabeshaLeapYear(year) ? 6 : 5;
}
return 30;
}
/**
* Check if a Habesha year is a leap year
*/
static isHabeshaLeapYear(year: number): boolean {
// Ethiopian leap year calculation
return year % 4 === 3;
}
/**
* Parse date string in various formats to Habesha date
*/
static parseHabeshaDate(dateString: string): HabeshaDate | null {
try {
// Try to parse as ISO string first, then convert
const gregorianDate = new Date(dateString);
if (!isNaN(gregorianDate.getTime())) {
return this.gregorianToHabesha(gregorianDate);
}
return null;
} catch {
return null;
}
}
/**
* Format date for display in forms
*/
static formatForDisplay(
date: Date | string | null | undefined,
language: "am" | "en" = "am"
): string {
// Handle null/undefined cases
if (!date) {
date = new Date();
}
// Convert string to Date if needed
let gregorianDate: Date;
if (typeof date === "string") {
gregorianDate = new Date(date);
} else {
gregorianDate = date;
}
// Validate that we have a valid Date object
if (!(gregorianDate instanceof Date) || isNaN(gregorianDate.getTime())) {
gregorianDate = new Date();
}
const habeshaDate = this.gregorianToHabesha(gregorianDate);
if (language === "en") {
const monthName = this.MONTH_NAMES_EN[habeshaDate.month - 1];
const dayName = this.DAY_NAMES_EN[gregorianDate.getDay()];
return `${dayName} ${monthName} ${habeshaDate.day}, ${habeshaDate.year}`;
}
return habeshaDate.formatted;
}
/**
* Get date range in Habesha calendar
*/
static getHabeshaDateRange(
startDate: Date,
endDate: Date
): {
start: HabeshaDate;
end: HabeshaDate;
formatted: string;
} {
const start = this.gregorianToHabesha(startDate);
const end = this.gregorianToHabesha(endDate);
return {
start,
end,
formatted: `${start.formatted} - ${end.formatted}`,
};
}
}
export default HabeshaCalendarUtils;

View File

@@ -0,0 +1,4 @@
// src/shared/constants/max-file-size.ts
// Default max file size in MB if not specified elsewhere
export const MAX_FILE_SIZE_MB = 500; // 10 MB

View File

@@ -0,0 +1,15 @@
import { NavOptions } from "../navigation/navigationtabs";
export const hasAccess = (
item: NavOptions,
userPermissions: string[],
userRoles: string[]
): boolean => {
const permsCheck =
item.perms?.some((perm) => userPermissions.includes(perm)) ?? true;
const rolesCheck =
item.roles?.some((role) => userRoles.includes(role)) ?? true;
return permsCheck || rolesCheck;
};

View File

@@ -0,0 +1,7 @@
import { refreshAuthToken } from "../services/authService";
export const getRefreshToken = async (refreshToken: string) => {
return await refreshAuthToken({
refreshToken: refreshToken,
});
};