mirror of
https://github.com/Tria-plc/emaui.git
synced 2026-08-26 19:12:50 +00:00
feat: integrate error handling across various components
- Added `useErrorHandler` hook to centralize error handling logic. - Updated components in the backoffice and portal applications to utilize the new error handling mechanism, replacing direct notify calls with `handleError` for improved error messaging. - Enhanced localization files to include generic error messages for better user feedback. - Refactored error handling in forms and API interactions to ensure consistent user experience across the application.
This commit is contained in:
@@ -19,7 +19,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCertificate } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import {
|
||||
useGetCertificationsQuery,
|
||||
useCreateCertificationMutation,
|
||||
@@ -75,6 +75,7 @@ function CertificationForm({
|
||||
export function CertificationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data, isLoading, isError } = useGetCertificationsQuery();
|
||||
const [createCert, { isLoading: isCreating }] = useCreateCertificationMutation();
|
||||
const [updateCert, { isLoading: isUpdating }] = useUpdateCertificationMutation();
|
||||
@@ -104,8 +105,8 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -116,8 +117,8 @@ export function CertificationPage() {
|
||||
notify.success(t('certification.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('certification.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ import { useForm } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconEdit, IconTrash, IconPlus, IconBriefcase, IconMap, IconCertificate, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationPage } from '../../location/pages/LocationPage';
|
||||
import { CertificationPage } from '../../certification/pages/CertificationPage';
|
||||
import {
|
||||
@@ -131,6 +131,7 @@ function ProfessionForm({ editingProf, deptOptions, isSubmitting, onSubmit, onCa
|
||||
function ProfessionTab() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: deptRes } = useGetOrganizationsQuery();
|
||||
const { data: profRes, isLoading, isError } = useGetProfessionsQuery();
|
||||
const [createProfession, { isLoading: isCreating }] = useCreateProfessionMutation();
|
||||
@@ -172,10 +173,10 @@ function ProfessionTab() {
|
||||
notify.success(t('configuration.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [deleteTarget, deleteProfession, closeDelete, t]);
|
||||
}, [deleteTarget, deleteProfession, closeDelete, handleError]);
|
||||
|
||||
const handleProfSubmit = useCallback(async (values: ProfFormValues) => {
|
||||
const name = { en: values.nameEn, am: values.nameAm };
|
||||
@@ -199,10 +200,10 @@ function ProfessionTab() {
|
||||
notify.success(t('configuration.created'));
|
||||
}
|
||||
resetProfForm();
|
||||
} catch {
|
||||
notify.error(t('configuration.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [editingProf, createProfession, updateProfession, resetProfForm, t]);
|
||||
}, [editingProf, createProfession, updateProfession, resetProfForm, handleError]);
|
||||
|
||||
const getDeptName = useCallback((deptId: string) => {
|
||||
const dept = departments.find((d) => d.id === deptId);
|
||||
|
||||
@@ -39,7 +39,7 @@ import {
|
||||
IconCheck,
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useGetExamQuery, useUpdateExamMutation, useAssignQuestionsMutation } from '../api/exam-api';
|
||||
import { useGetQuestionsQuery } from '../../question/api/question-api';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
@@ -69,6 +69,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
export function ExamDetailPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const printRef = useRef<HTMLDivElement>(null);
|
||||
@@ -154,8 +155,8 @@ export function ExamDetailPage() {
|
||||
await assignQuestions({ examId: exam.id, questionIds, remark: undefined }).unwrap();
|
||||
notify.success('Questions assigned');
|
||||
closeAssign();
|
||||
} catch {
|
||||
notify.error('Failed to assign questions');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -25,7 +25,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle, IconCalendar, IconClipboardList } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
useGetExamsQuery,
|
||||
@@ -149,6 +149,7 @@ function ExamForm({
|
||||
export function ExamPage() {
|
||||
const navigate = useNavigate();
|
||||
const { t, i18n } = useTranslation();
|
||||
const { handleError } = useErrorHandler();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetExamsQuery();
|
||||
@@ -195,8 +196,8 @@ export function ExamPage() {
|
||||
notify.success(t('exam.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -207,8 +208,8 @@ export function ExamPage() {
|
||||
notify.success(t('exam.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('exam.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Table, Badge, ActionIcon, Text } from '@mantine/core';
|
||||
import { IconTrash } from '@tabler/icons-react';
|
||||
import { useGetItemsQuery, useDeleteItemMutation, type Item } from '../api/item-api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
|
||||
const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
DRAFT: 'gray',
|
||||
@@ -12,13 +12,14 @@ const STATUS_COLORS: Record<Item['status'], string> = {
|
||||
export function ItemTable() {
|
||||
const { data, isLoading } = useGetItemsQuery({});
|
||||
const [deleteItem] = useDeleteItemMutation();
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const handleDelete = async (id: string) => {
|
||||
try {
|
||||
await deleteItem(id).unwrap();
|
||||
notify.success('Item deleted');
|
||||
} catch {
|
||||
notify.error('Failed to delete item');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -22,7 +22,7 @@ import {
|
||||
useUpdateLocationTypeMutation,
|
||||
useDeleteLocationTypeMutation,
|
||||
} from '../api/location-api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
|
||||
interface LocationTypeFormValues {
|
||||
code: string;
|
||||
@@ -34,6 +34,7 @@ interface LocationTypeFormValues {
|
||||
export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClose: () => void }) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypes, isLoading } = useGetLocationTypesQuery();
|
||||
const [createType] = useCreateLocationTypeMutation();
|
||||
const [updateType] = useUpdateLocationTypeMutation();
|
||||
@@ -84,8 +85,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
try {
|
||||
await deleteType(id).unwrap();
|
||||
notify.success(t('location.typeDeleted'));
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -99,8 +100,8 @@ export function LocationTypeModal({ opened, onClose }: { opened: boolean; onClos
|
||||
notify.success(t('location.typeCreated'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('location.typeError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconSettings, IconMap, IconInfoCircle, IconPlus } from '@tabler/icons-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { LocationTree } from '../components/LocationTree';
|
||||
import { LocationDetail } from '../components/LocationDetail';
|
||||
import { LocationForm } from '../components/LocationForm';
|
||||
@@ -33,6 +33,7 @@ import type { Location } from '../types/location';
|
||||
export function LocationPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: locationTypesRes, isLoading: typesLoading } = useGetLocationTypesQuery();
|
||||
const [createLocation, { isLoading: isCreating }] = useCreateLocationMutation();
|
||||
const [updateLocation, { isLoading: isUpdating }] = useUpdateLocationMutation();
|
||||
@@ -82,11 +83,11 @@ export function LocationPage() {
|
||||
closeFormModal();
|
||||
setEditingLocation(null);
|
||||
setParentLocation(null);
|
||||
} catch {
|
||||
notify.error(t('location.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
},
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, t],
|
||||
[editingLocation, createLocation, updateLocation, closeFormModal, handleError],
|
||||
);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
@@ -96,10 +97,10 @@ export function LocationPage() {
|
||||
notify.success(t('location.deleted'));
|
||||
setSelectedLocation(null);
|
||||
closeDeleteModal();
|
||||
} catch {
|
||||
notify.error(t('location.deleteError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, t]);
|
||||
}, [selectedLocation, deleteLocation, closeDeleteModal, handleError]);
|
||||
|
||||
if (typesLoading) {
|
||||
return (
|
||||
|
||||
@@ -42,7 +42,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { setUser } from '@ema-platform/auth';
|
||||
import type { AuthUser } from '@ema-platform/auth';
|
||||
@@ -77,6 +77,7 @@ export function ProfilePage() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const layoutMode = useAppSelector((state) => state.preferences.layoutMode);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -156,8 +157,8 @@ export function ProfilePage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@@ -208,8 +209,8 @@ export function ProfilePage() {
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
|
||||
@@ -20,7 +20,7 @@ import {
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { IconEdit, IconTrash, IconPlus, IconInfoCircle } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useGetCertificationsQuery } from '../../certification/api/certification-api';
|
||||
import {
|
||||
useGetQuestionsQuery,
|
||||
@@ -102,6 +102,7 @@ function QuestionForm({
|
||||
export function QuestionPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: certRes } = useGetCertificationsQuery();
|
||||
const { data, isLoading, isError } = useGetQuestionsQuery();
|
||||
const [createQ, { isLoading: isCreating }] = useCreateQuestionMutation();
|
||||
@@ -140,8 +141,8 @@ export function QuestionPage() {
|
||||
notify.success(t('question.created'));
|
||||
}
|
||||
resetForm();
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -152,8 +153,8 @@ export function QuestionPage() {
|
||||
notify.success(t('question.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('question.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -17,7 +17,7 @@ import {
|
||||
Alert,
|
||||
} from '@mantine/core';
|
||||
import { IconInfoCircle, IconCheck, IconX } from '@tabler/icons-react';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useApiQuery } from '@ema-platform/api';
|
||||
import { useCreateResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import type { Exam } from '../../exam/types/exam';
|
||||
@@ -42,6 +42,7 @@ export function RecordResultModal({
|
||||
}) {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const [seafarerSearch, setSeafarerSearch] = useState('');
|
||||
const [selectedSeafarerId, setSelectedSeafarerId] = useState<string | null>(null);
|
||||
const [scores, setScores] = useState<Record<string, number>>({});
|
||||
@@ -106,8 +107,8 @@ export function RecordResultModal({
|
||||
setRemark('');
|
||||
setSeafarerSearch('');
|
||||
onClose();
|
||||
} catch {
|
||||
notify.error(t('result.recordModal.saveError'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IconChartBar,
|
||||
IconSearch,
|
||||
} from '@tabler/icons-react';
|
||||
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { useGetResultsQuery, useLazyGetResultQuery, useDeleteResultMutation, useUpdateResultMutation } from '../api/result-api';
|
||||
import { useGetExamsQuery } from '../../exam/api/exam-api';
|
||||
@@ -95,6 +95,7 @@ function InfoRow({ label, value }: { label: string; value: string }) {
|
||||
export function ResultPage() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const locale = i18n.language as 'en' | 'am';
|
||||
const { handleError } = useErrorHandler();
|
||||
const { data: examRes } = useGetExamsQuery();
|
||||
const { data, isLoading, isError } = useGetResultsQuery();
|
||||
const [fetchDetail, { data: detailResult, isFetching: isDetailLoading }] = useLazyGetResultQuery();
|
||||
@@ -180,8 +181,8 @@ export function ResultPage() {
|
||||
}).unwrap();
|
||||
notify.success(t('result.updated'));
|
||||
closeDetail();
|
||||
} catch {
|
||||
notify.error(t('result.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setDetailSaving(false);
|
||||
}
|
||||
@@ -201,8 +202,8 @@ export function ResultPage() {
|
||||
notify.success(t('result.deleted'));
|
||||
closeDelete();
|
||||
setDeleteTarget(null);
|
||||
} catch {
|
||||
notify.error(t('result.error'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -8,6 +8,17 @@ export const am: Translations = {
|
||||
tagline: 'የቁጥጥር ማዕከል',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።',
|
||||
authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።',
|
||||
permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።',
|
||||
notFoundError: 'የተጠየቀው ንጥል አልተገኘም።',
|
||||
fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።',
|
||||
networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'ቋንቋ',
|
||||
en: 'English',
|
||||
|
||||
@@ -6,6 +6,17 @@ export const en = {
|
||||
tagline: 'Control Center',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
validationError: 'Please check your input and try again.',
|
||||
authError: 'Your session has expired. Please sign in again.',
|
||||
permissionError: "You don't have permission to perform this action.",
|
||||
notFoundError: 'The requested item was not found.',
|
||||
fileTooLarge: 'The file is too large to upload.',
|
||||
networkError: 'Network error. Check your connection and try again.',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
rem,
|
||||
} from '@mantine/core';
|
||||
import { notifications } from '@mantine/notifications';
|
||||
import { useErrorHandler } from '@ema-platform/ui';
|
||||
import {
|
||||
IconArrowRight,
|
||||
IconBook2,
|
||||
@@ -98,6 +99,7 @@ export function CertificatesPage() {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [previewTitle, setPreviewTitle] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const openPreview = async (profileId: string, title: string) => {
|
||||
setLoading(true);
|
||||
@@ -107,11 +109,7 @@ export function CertificatesPage() {
|
||||
setPreviewTitle(title);
|
||||
setPreviewUrl(url);
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not generate certificate',
|
||||
});
|
||||
handleError(err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@@ -127,11 +125,7 @@ export function CertificatesPage() {
|
||||
message: 'Certificate PDF downloaded successfully',
|
||||
});
|
||||
} catch (err) {
|
||||
notifications.show({
|
||||
color: 'red',
|
||||
title: 'Error',
|
||||
message: err instanceof Error ? err.message : 'Could not download certificate',
|
||||
});
|
||||
handleError(err);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -23,7 +23,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
|
||||
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
|
||||
import {
|
||||
@@ -122,6 +122,7 @@ export function ProfileSetupPage() {
|
||||
const [addressTrigger] = useApiMutation<unknown>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
|
||||
const { handleError } = useErrorHandler();
|
||||
const fetched = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,8 +266,8 @@ export function ProfileSetupPage() {
|
||||
|
||||
notify.success('Profile setup complete!');
|
||||
navigate('/dashboard');
|
||||
} catch {
|
||||
notify.error('Failed to save profile. Please try again.');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -43,7 +43,7 @@ import { useForm } from 'react-hook-form';
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { z } from 'zod';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { notify, PageHeader } from '@ema-platform/ui';
|
||||
import { notify, PageHeader, useErrorHandler } from '@ema-platform/ui';
|
||||
import { useApiMutation } from '@ema-platform/api';
|
||||
import { authStorage, setUser, setCurrentProfile } from '@ema-platform/auth';
|
||||
import type { CurrentProfile } from '@ema-platform/auth';
|
||||
@@ -86,6 +86,7 @@ export function ProfilePage() {
|
||||
const user = useAppSelector((state) => state.auth.user);
|
||||
const currentProfile = useAppSelector((state) => state.auth.currentProfile);
|
||||
const { colorScheme, setColorScheme } = useMantineColorScheme();
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
const [updateTrigger] = useApiMutation<AuthUser>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
@@ -255,8 +256,8 @@ export function ProfilePage() {
|
||||
dispatch(setUser(me));
|
||||
|
||||
notify.success(t('profile.profileUpdated'));
|
||||
} catch {
|
||||
notify.error(t('profile.updateFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingProfile(false);
|
||||
}
|
||||
@@ -286,8 +287,8 @@ export function ProfilePage() {
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Profile updated');
|
||||
} catch {
|
||||
notify.error('Failed to update profile');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingMaritime(false);
|
||||
}
|
||||
@@ -320,8 +321,8 @@ export function ProfilePage() {
|
||||
}).unwrap();
|
||||
|
||||
notify.success('Address updated');
|
||||
} catch {
|
||||
notify.error('Failed to update address');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingAddress(false);
|
||||
}
|
||||
@@ -366,8 +367,8 @@ export function ProfilePage() {
|
||||
|
||||
notify.success(t('profile.passwordChanged'));
|
||||
resetPassword();
|
||||
} catch {
|
||||
notify.error(t('profile.passwordFailed'));
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setIsSavingPassword(false);
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate, useParams } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { Seafarer } from './SeafarerRegistryPage';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -569,12 +569,13 @@ export function SeafarerProfilePage() {
|
||||
const [medicalModal, medicalModalHandlers] = useDisclosure(false);
|
||||
const [seaServiceModal, seaServiceModalHandlers] = useDisclosure(false);
|
||||
const [certModal, certModalHandlers] = useDisclosure(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
useEffect(() => {
|
||||
if (!id) return;
|
||||
fetchSeafarerProfile(id)
|
||||
.then(setProfile)
|
||||
.catch(() => notify.error('Failed to load seafarer profile.'))
|
||||
.catch((e) => handleError(e))
|
||||
.finally(() => setLoading(false));
|
||||
}, [id]);
|
||||
|
||||
@@ -584,8 +585,8 @@ export function SeafarerProfilePage() {
|
||||
await updateSeafarerStatus(profile.id, newStatus);
|
||||
setProfile((p) => p ? { ...p, status: newStatus } : p);
|
||||
notify.success(`Status updated to ${newStatus}.`);
|
||||
} catch {
|
||||
notify.error('Failed to update status.');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -33,7 +33,7 @@ import {
|
||||
IconUser,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify, BilingualInput } from '@ema-platform/ui';
|
||||
import { notify, BilingualInput, useErrorHandler } from '@ema-platform/ui';
|
||||
import type { BilingualValue } from '@ema-platform/ui';
|
||||
import { AmharicDatePicker, toEthiopicDateLabel } from '../../../components/AmharicDatePicker';
|
||||
import { LocationPicker } from '../../location/components/LocationPicker';
|
||||
@@ -257,6 +257,7 @@ export function SeafarerRegistrationPage() {
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
// Step 1 — Personal Information
|
||||
const [firstName, setFirstName] = useState<BilingualValue>({ en: '', am: '' });
|
||||
@@ -312,8 +313,8 @@ export function SeafarerRegistrationPage() {
|
||||
});
|
||||
notify.success(`Registration submitted! Reference: ${result.referenceId}`);
|
||||
navigate('/applications');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -35,7 +35,7 @@ import {
|
||||
IconX,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
@@ -194,11 +194,12 @@ export function SeafarerRegistryPage() {
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [search, setSearch] = useState('');
|
||||
const [statusFilter, setStatusFilter] = useState<string | null>(null);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
useEffect(() => {
|
||||
fetchSeafarers()
|
||||
.then(setSeafarers)
|
||||
.catch(() => notify.error('Failed to load seafarers.'))
|
||||
.catch((e) => handleError(e))
|
||||
.finally(() => setLoading(false));
|
||||
}, []);
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ import {
|
||||
IconUpload,
|
||||
} from '@tabler/icons-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { notify } from '@ema-platform/ui';
|
||||
import { notify, useErrorHandler } from '@ema-platform/ui';
|
||||
import { TelebirrPayment } from '../../payment/components/TelebirrPayment';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -212,6 +212,7 @@ export function SeamanBookApplicationPage() {
|
||||
const [active, setActive] = useState(0);
|
||||
const [completed, setCompleted] = useState<number[]>([]);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
const { handleError } = useErrorHandler();
|
||||
|
||||
// BST
|
||||
const [bstData, setBstData] = useState<Record<string, { certNumber: string; issuer: string; issueDate: string; expiryDate: string; file: File | null }>>(() =>
|
||||
@@ -268,8 +269,8 @@ export function SeamanBookApplicationPage() {
|
||||
await new Promise((r) => setTimeout(r, 1400));
|
||||
notify.success('Application submitted! Reference: SB-BTC-2025-001');
|
||||
navigate('/seaman-book');
|
||||
} catch {
|
||||
notify.error('Submission failed. Please try again.');
|
||||
} catch (e) {
|
||||
handleError(e);
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
|
||||
@@ -7,6 +7,17 @@ export const am: Translations = {
|
||||
tagline: 'የባሕር ፍቃድና የምስክር ወረቀት አገልግሎቶች',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'የሆነ ስህተት ተፈጥሯል። እባክዎ እንደገና ይሞክሩ።',
|
||||
serverError: 'የሰርቨር ስህተት። እባክዎ ቆየት ብለው እንደገና ይሞክሩ።',
|
||||
validationError: 'እባክዎ ያስገቡትን መረጃ ያረጋግጡና እንደገና ይሞክሩ።',
|
||||
authError: 'ክፍለ ጊዜዎ አልቋል። እባክዎ እንደገና ይግቡ።',
|
||||
permissionError: 'ይህን ድርጊት ለመፈጸም ፈቃድ የለዎትም።',
|
||||
notFoundError: 'የተጠየቀው ንጥል አልተገኘም።',
|
||||
fileTooLarge: 'ፋይሉ ለመስቀል በጣም ትልቅ ነው።',
|
||||
networkError: 'የአውታረ መረብ ስህተት። ግንኙነትዎን አረጋግጠው እንደገና ይሞክሩ።',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'ቋንቋ',
|
||||
en: 'English',
|
||||
|
||||
@@ -5,6 +5,17 @@ export const en = {
|
||||
tagline: 'Maritime licensing & certification services',
|
||||
},
|
||||
|
||||
msg: {
|
||||
genericError: 'Something went wrong. Please try again.',
|
||||
serverError: 'Server error. Please try again later.',
|
||||
validationError: 'Please check your input and try again.',
|
||||
authError: 'Your session has expired. Please sign in again.',
|
||||
permissionError: "You don't have permission to perform this action.",
|
||||
notFoundError: 'The requested item was not found.',
|
||||
fileTooLarge: 'The file is too large to upload.',
|
||||
networkError: 'Network error. Check your connection and try again.',
|
||||
},
|
||||
|
||||
language: {
|
||||
label: 'Language',
|
||||
en: 'English',
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -24,7 +24,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 { notify, useErrorHandler } from "@ema-platform/ui";
|
||||
import { AuthShell } from "../components/AuthShell";
|
||||
import { loginSuccess, setUser, setCurrentProfile } from "../store/auth.slice";
|
||||
import type {
|
||||
@@ -52,6 +52,7 @@ export function LoginPage() {
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [rememberMe, setRememberMe] = useState(true);
|
||||
const [serverError, setServerError] = useState<string | null>(null);
|
||||
const { handleError } = useErrorHandler();
|
||||
const [loginTrigger] = useApiMutation<LoginPayload>();
|
||||
const [meTrigger] = useApiMutation<AuthUser>();
|
||||
const [profileCheckTrigger] = useApiMutation<{
|
||||
@@ -118,11 +119,7 @@ export function LoginPage() {
|
||||
|
||||
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));
|
||||
} finally {
|
||||
localStorage.setItem("rememberMe", String(rememberMe));
|
||||
setIsLoading(false);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -48,6 +48,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,
|
||||
@@ -75,11 +76,7 @@ export function OTPVerificationPage() {
|
||||
notify.success('Phone number verified successfully');
|
||||
navigate(needsProfile ? '/profile-setup' : 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));
|
||||
}
|
||||
};
|
||||
|
||||
@@ -96,11 +93,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 } from '@ema-platform/ui';
|
||||
import { AuthShell } from '../components/AuthShell';
|
||||
import { loginSuccess, setUser } from '../store/auth.slice';
|
||||
import type { AuthUser } from '../types/auth.types';
|
||||
@@ -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;
|
||||
@@ -126,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));
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
@@ -2,6 +2,7 @@ export * from './lib/input/BilingualInput';
|
||||
export * from './lib/feedback/ConfirmModal';
|
||||
export * from './lib/feedback/ApiErrorAlert';
|
||||
export * from './lib/feedback/notify';
|
||||
export * from './lib/feedback/use-error-handler';
|
||||
export * from './lib/layout/AppHeader';
|
||||
export * from './lib/layout/AppSidebar';
|
||||
export * from './lib/layout/BrandAvatar';
|
||||
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user