This commit is contained in:
Estifo77
2026-07-28 10:26:55 +03:00
parent cdec4c435b
commit 173d6eccb6
3 changed files with 202 additions and 129 deletions

View File

@@ -279,7 +279,7 @@ function ProfessionTab() {
{
header: t("configuration.name"),
cell: ({ row }) => row.original.name[locale],
enabled:true
enabled: true,
},
{
header: t("configuration.description"),
@@ -294,7 +294,7 @@ function ProfessionTab() {
cell: ({ row }) => getDeptName(row.original.departmentId),
},
{
header: "",
header: "actions",
size: 90,
cell: ({ row }) => (
<Group gap="xs">

View File

@@ -1,4 +1,4 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useEffect, useMemo, useRef, useState } from "react";
import {
Box,
Button,
@@ -9,7 +9,7 @@ import {
Text,
Title,
rem,
} from '@mantine/core';
} from "@mantine/core";
import {
IconArrowLeft,
IconArrowRight,
@@ -18,31 +18,42 @@ import {
IconLogout2,
IconMapPin,
IconUser,
} from '@tabler/icons-react';
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, useErrorHandler } from '@ema-platform/ui';
import { authStorage, setUser, logout, type AuthUser } from '@ema-platform/auth';
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
} from "@tabler/icons-react";
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, useErrorHandler } from "@ema-platform/ui";
import {
authStorage,
setUser,
logout,
type AuthUser,
} from "@ema-platform/auth";
import { useAppDispatch, useAppSelector } from "../../../store/hooks";
import {
ProfileFormContent,
profileSchema,
type ProfileValues,
} from '../../profile/components/ProfileFormContent';
} from "../../profile/components/ProfileFormContent";
import {
AddressFormContent,
addressSchema,
type AddressValues,
} from '../../profile/components/AddressFormContent';
} from "../../profile/components/AddressFormContent";
const STEPS = [
{ label: 'Profile', icon: IconUser },
{ label: 'Address', icon: IconMapPin },
{ label: "Profile", icon: IconUser },
{ label: "Address", icon: IconMapPin },
];
function StepIndicator({ active, completed }: { active: number; completed: number[] }) {
function StepIndicator({
active,
completed,
}: {
active: number;
completed: number[];
}) {
return (
<Box mb={32}>
<Group gap={0} align="center" wrap="nowrap">
@@ -50,31 +61,41 @@ function StepIndicator({ active, completed }: { active: number; completed: numbe
const isDone = completed.includes(i);
const isCurrent = active === i;
return (
<Group key={i} gap={0} align="center" style={{ flex: i < STEPS.length - 1 ? 1 : 'none' }}>
<Group
key={i}
gap={0}
align="center"
style={{ flex: i < STEPS.length - 1 ? 1 : "none" }}
>
<Stack gap={4} align="center" style={{ minWidth: rem(40) }}>
<Box
style={{
width: rem(40),
height: rem(40),
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
borderRadius: "50%",
display: "flex",
alignItems: "center",
justifyContent: "center",
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
? "var(--mantine-color-blue-8)"
: isCurrent
? 'var(--mantine-color-blue-7)'
: 'var(--mantine-color-gray-1)',
border: isCurrent ? '2.5px solid var(--mantine-color-blue-5)' : '2px solid transparent',
boxShadow: isCurrent || isDone ? '0 2px 8px rgba(34, 139, 230, 0.2)' : 'none',
? "var(--mantine-color-blue-7)"
: "var(--mantine-color-gray-1)",
border: isCurrent
? "2.5px solid var(--mantine-color-blue-5)"
: "2px solid transparent",
boxShadow:
isCurrent || isDone
? "0 2px 8px rgba(34, 139, 230, 0.2)"
: "none",
flexShrink: 0,
transition: 'all 0.2s ease',
transition: "all 0.2s ease",
}}
>
{isDone ? (
<IconCheck size={18} color="white" stroke={2.5} />
) : (
<Text fw={700} fz="sm" c={isCurrent ? 'white' : 'gray.5'}>
<Text fw={700} fz="sm" c={isCurrent ? "white" : "gray.5"}>
{i + 1}
</Text>
)}
@@ -82,8 +103,8 @@ function StepIndicator({ active, completed }: { active: number; completed: numbe
<Text
fz="xs"
fw={isCurrent ? 700 : 400}
c={isCurrent ? 'blue.7' : 'dimmed'}
style={{ whiteSpace: 'nowrap' }}
c={isCurrent ? "blue.7" : "dimmed"}
style={{ whiteSpace: "nowrap" }}
>
{step.label}
</Text>
@@ -95,8 +116,8 @@ function StepIndicator({ active, completed }: { active: number; completed: numbe
flex: 1,
height: rem(2),
backgroundColor: isDone
? 'var(--mantine-color-blue-8)'
: 'var(--mantine-color-gray-2)',
? "var(--mantine-color-blue-8)"
: "var(--mantine-color-gray-2)",
marginBottom: rem(22),
}}
/>
@@ -116,19 +137,24 @@ export function ProfileSetupPage() {
const [active, setActive] = useState(0);
const [completed, setCompleted] = useState<number[]>([]);
const [submitting, setSubmitting] = useState(false);
const [professions, setProfessions] = useState<Array<{ id: string; name: { en: string } }>>([]);
const [professions, setProfessions] = useState<
Array<{ id: string; name: { en: string } }>
>([]);
const [professionsLoading, setProfessionsLoading] = useState(true);
const [profileTrigger] = useApiMutation<{ id: string }>();
const [addressTrigger] = useApiMutation<unknown>();
const [addressTrigger] = useApiMutation<{ id: string }>();
const [meTrigger] = useApiMutation<AuthUser>();
const [fetchProfessions] = useApiMutation<{ count: number; items: Array<{ id: string; name: { en: string } }> }>();
const [fetchProfessions] = useApiMutation<{
count: number;
items: Array<{ id: string; name: { en: string } }>;
}>();
const { handleError } = useErrorHandler();
const fetched = useRef(false);
useEffect(() => {
if (fetched.current) return;
fetched.current = true;
fetchProfessions({ url: '/professions?take=100', method: 'GET' })
fetchProfessions({ url: "/professions?take=100", method: "GET" })
.unwrap()
.then((data) => setProfessions(data.items ?? []))
.catch(() => setProfessions([]))
@@ -148,36 +174,45 @@ export function ProfileSetupPage() {
return map;
}, [professions]);
const nameParts = useMemo(() => (user?.name?.en || '').trim().split(/\s+/), [user]);
const profileDefaults: ProfileValues = useMemo(() => ({
professionId: '',
firstName: nameParts[0] || '',
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(' ') : '',
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : '',
gender: '',
dob: '',
pob: '',
maritalStatus: '',
}), [nameParts]);
const nameParts = useMemo(
() => (user?.name?.en || "").trim().split(/\s+/),
[user],
);
const profileDefaults: ProfileValues = useMemo(
() => ({
professionId: "",
firstName: nameParts[0] || "",
middleName: nameParts.length > 2 ? nameParts.slice(1, -1).join(" ") : "",
lastName: nameParts.length > 1 ? nameParts[nameParts.length - 1] : "",
gender: "",
dob: "",
pob: "",
maritalStatus: "",
}),
[nameParts],
);
const addressDefaults: AddressValues = useMemo(() => ({
idType: '',
idNumber: '',
nationality: '',
primaryPhoneNumber: user?.phoneNumber || '',
secondaryPhoneNumber: '',
email: user?.email || '',
regionId: '',
cityId: '',
subcityId: '',
woredaId: '',
kebeleId: '',
streetAddress: '',
postalAddress: '',
emergencyContactName: '',
emergencyContactPhone: '',
emergencyContactRelation: '',
}), [user]);
const addressDefaults: AddressValues = useMemo(
() => ({
idType: "",
idNumber: "",
nationality: "",
primaryPhoneNumber: user?.phoneNumber || "",
secondaryPhoneNumber: "",
email: user?.email || "",
regionId: "",
cityId: "",
subcityId: "",
woredaId: "",
kebeleId: "",
streetAddress: "",
postalAddress: "",
emergencyContactName: "",
emergencyContactPhone: "",
emergencyContactRelation: "",
}),
[user],
);
const {
register: profileRegister,
@@ -218,29 +253,11 @@ export function ProfileSetupPage() {
try {
const pv = profileWatch();
const av = addressWatch();
const selectedProfessionName = professionNameMap[pv.professionId] ?? '';
const selectedProfessionName = professionNameMap[pv.professionId] ?? "";
const profileResult = await profileTrigger({
url: '/profiles',
method: 'POST',
body: {
userId: user?.id,
type: 'SEAFARER',
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
await addressTrigger({
url: `/addresss/profile/${profileResult.id}`,
method: 'POST',
const addressData = await addressTrigger({
url: `/addresss`,
method: "POST",
body: {
idType: av.idType,
idNumber: av.idNumber,
@@ -261,11 +278,30 @@ export function ProfileSetupPage() {
},
}).unwrap();
const me = await meTrigger({ url: '/auth/me', method: 'GET' }).unwrap();
const profileResult = await profileTrigger({
url: "/profiles",
method: "POST",
body: {
userId: user?.id,
type: "SEAFARER",
professionId: pv.professionId,
firstName: pv.firstName,
middleName: pv.middleName,
lastName: pv.lastName,
gender: pv.gender,
dob: pv.dob,
pob: pv.pob || undefined,
maritalStatus: pv.maritalStatus,
addressId: addressData.id,
},
}).unwrap();
authStorage.setProfileId(profileResult.id);
const me = await meTrigger({ url: "/auth/me", method: "GET" }).unwrap();
dispatch(setUser(me));
notify.success('Profile setup complete!');
navigate('/dashboard');
notify.success("Profile setup complete!");
navigate("/dashboard");
} catch (e) {
handleError(e);
} finally {
@@ -333,7 +369,7 @@ export function ProfileSetupPage() {
leftSection={<IconLogout2 size={16} />}
onClick={() => {
dispatch(logout());
navigate('/login');
navigate("/login");
}}
>
Sign out
@@ -349,7 +385,10 @@ export function ProfileSetupPage() {
</Button>
)}
{active < STEPS.length - 1 ? (
<Button rightSection={<IconArrowRight size={16} />} onClick={onNext}>
<Button
rightSection={<IconArrowRight size={16} />}
onClick={onNext}
>
Next Step
</Button>
) : (

View File

@@ -1,4 +1,4 @@
import { ReactNode, useState } from 'react';
import { ReactNode, useState } from "react";
import {
Table,
Button,
@@ -11,9 +11,9 @@ import {
Center,
Paper,
Badge,
} from '@mantine/core';
import { IconRefresh, IconEye, IconInbox } from '@tabler/icons-react';
import { useTranslation } from 'react-i18next';
} from "@mantine/core";
import { IconRefresh, IconEye, IconInbox } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
export interface AdvancedColumn<T> {
header: ReactNode;
@@ -21,7 +21,7 @@ export interface AdvancedColumn<T> {
accessorKey?: string;
cell?: (ctx: { row: { original: T }; value: unknown }) => ReactNode;
size?: number;
align?: 'left' | 'center' | 'right';
align?: "left" | "center" | "right";
/** Whether column starts visible. Default true. */
enabled?: boolean;
}
@@ -45,10 +45,15 @@ interface AdvancedTableProps<T> {
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,
);
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 }>({
@@ -64,7 +69,9 @@ export function AdvancedTable<T extends { id?: string | number }>({
emptyText,
}: AdvancedTableProps<T>) {
const { t } = useTranslation();
const [visible, setVisible] = useState<boolean[]>(columns.map((c) => c.enabled ?? true));
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
@@ -73,57 +80,80 @@ export function AdvancedTable<T extends { id?: string | number }>({
const shownColumns = columns.filter((_, i) => visible[i] ?? true);
return (
<Paper withBorder radius="md" p="md">
<Paper radius="md" p="md">
<Group justify="space-between" mb="md">
<Group gap="xs">
<Text fw={600}>{tableName}</Text>
<Badge variant="light" size="sm" radius="sm">
{itemCount}
</Badge>
<Text fw={600}>{""}</Text>
</Group>
<Group gap="xs">
<Menu closeOnItemClick={false} shadow="md" position="bottom-end" width={220}>
<Menu
closeOnItemClick={false}
shadow="md"
position="bottom-end"
width={220}
>
{refresh && (
<Button
variant="default"
size="sm"
leftSection={<IconRefresh size={16} />}
onClick={refresh}
loading={isLoading}
>
{t("common.refresh", "Refresh")}
</Button>
)}
<Menu.Target>
<Button variant="default" size="sm" leftSection={<IconEye size={16} />}>
{t('common.view', 'View')}
<Button
variant="default"
size="sm"
leftSection={<IconEye size={16} />}
>
{t("common.view", "View")}
</Button>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>{t('common.toggleColumns', 'Toggle columns')}</Menu.Label>
<Menu.Label>
{t("common.toggleColumns", "Toggle columns")}
</Menu.Label>
{columns.map((col, i) => (
<Menu.Item key={i} onClick={() => toggleColumn(i)}>
<Checkbox
label={col.header}
checked={visible[i] ?? true}
disabled={visible.filter(Boolean).length === 1 && (visible[i] ?? true)}
disabled={
visible.filter(Boolean).length === 1 &&
(visible[i] ?? true)
}
readOnly
tabIndex={-1}
styles={{ input: { cursor: 'pointer' }, label: { cursor: 'pointer' } }}
styles={{
input: { cursor: "pointer" },
label: { cursor: "pointer" },
}}
/>
</Menu.Item>
))}
</Menu.Dropdown>
</Menu>
{refresh && (
<Button
variant="default"
size="sm"
leftSection={<IconRefresh size={16} />}
onClick={refresh}
loading={isLoading}
>
{t('common.refresh', 'Refresh')}
</Button>
)}
</Group>
</Group>
<Table.ScrollContainer minWidth={480}>
<Table striped highlightOnHover withTableBorder withColumnBorders verticalSpacing="sm">
<Table
striped
highlightOnHover
withTableBorder
withColumnBorders
verticalSpacing="sm"
>
<Table.Thead>
<Table.Tr>
{shownColumns.map((col, i) => (
<Table.Th key={i} style={{ width: col.size, textAlign: col.align }}>
<Table.Th
key={i}
style={{ width: col.size, textAlign: col.align }}
>
{col.header}
</Table.Th>
))}
@@ -144,7 +174,9 @@ export function AdvancedTable<T extends { id?: string | number }>({
<Center py="xl">
<Group gap="xs" c="dimmed">
<IconInbox size={18} />
<Text c="dimmed">{emptyText ?? t('common.noResult', 'No results')}</Text>
<Text c="dimmed">
{emptyText ?? t("common.noResult", "No results")}
</Text>
</Group>
</Center>
</Table.Td>
@@ -156,7 +188,9 @@ export function AdvancedTable<T extends { id?: string | number }>({
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) ?? '-'}
{col.cell
? col.cell({ row: { original: row }, value })
: ((value as ReactNode) ?? "-")}
</Table.Td>
);
})}