Project Init

This commit is contained in:
Muluhabt
2026-05-29 15:23:46 +03:00
commit 2fbc557aac
67387 changed files with 6063341 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
import { baseApi } from '@ema-platform/api';
import type { LoginPayload } from '../types/auth.types';
interface LoginArgs {
email: string;
password: string;
}
interface RefreshArgs {
refreshToken: string;
}
const authApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
login: builder.mutation<LoginPayload, LoginArgs>({
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
}),
refresh: builder.mutation<{ token: string }, RefreshArgs>({
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
}),
logout: builder.mutation<{ message: string }, void>({
query: () => ({ url: '/auth/logout', method: 'POST' }),
}),
}),
overrideExisting: false,
});
export const { useLoginMutation, useRefreshMutation, useLogoutMutation } = authApi;

View File

@@ -0,0 +1,79 @@
import {
TextInput,
PasswordInput,
Button,
Stack,
Title,
Paper,
Text,
} from '@mantine/core';
import { useForm } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { z } from 'zod';
import { useNavigate } from 'react-router-dom';
import { useLoginMutation } from '../api/auth-api';
import { useAuth } from '../hooks/useAuth';
import { notify } from '@ema-platform/ui';
const schema = z.object({
email: z.string().email('Enter a valid email'),
password: z.string().min(6, 'Password must be at least 6 characters'),
});
type FormValues = z.infer<typeof schema>;
export function LoginForm() {
const navigate = useNavigate();
const { login } = useAuth();
const [loginMutate, { isLoading }] = useLoginMutation();
const {
register,
handleSubmit,
formState: { errors },
} = useForm<FormValues>({
resolver: zodResolver(schema),
});
const onSubmit = async (values: FormValues) => {
try {
const result = await loginMutate(values).unwrap();
login(result);
navigate('/dashboard');
} catch {
notify.error('Invalid email or password');
}
};
return (
<Paper p="xl" shadow="md" radius="md">
<Stack gap="lg">
<Stack gap={4}>
<Title order={2}>Sign in</Title>
<Text c="dimmed" size="sm">
Enter your credentials to access the backoffice
</Text>
</Stack>
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap="md">
<TextInput
label="Email"
placeholder="you@example.com"
error={errors.email?.message}
{...register('email')}
/>
<PasswordInput
label="Password"
placeholder="Your password"
error={errors.password?.message}
{...register('password')}
/>
<Button type="submit" loading={isLoading} fullWidth mt="sm">
Sign in
</Button>
</Stack>
</form>
</Stack>
</Paper>
);
}

View File

@@ -0,0 +1,17 @@
import { useAppDispatch, useAppSelector } from '../../../store/hooks';
import { loginSuccess, logout as logoutAction, hydrateAuth } from '../store/auth.slice';
import type { LoginPayload } from '../types/auth.types';
export function useAuth() {
const dispatch = useAppDispatch();
const { user, token, isAuthenticated } = useAppSelector((s) => s.auth);
return {
user,
token,
isAuthenticated,
login: (payload: LoginPayload) => dispatch(loginSuccess(payload)),
logout: () => dispatch(logoutAction()),
hydrate: () => dispatch(hydrateAuth()),
};
}

View File

@@ -0,0 +1,5 @@
import { LoginForm } from '../components/LoginForm';
export function LoginPage() {
return <LoginForm />;
}

View File

@@ -0,0 +1,42 @@
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
import type { AuthState, LoginPayload } from '../types/auth.types';
import { authStorage } from '../utils/auth-storage';
const initialState: AuthState = {
user: null,
token: null,
isAuthenticated: false,
};
const authSlice = createSlice({
name: 'auth',
initialState,
reducers: {
loginSuccess(state, action: PayloadAction<LoginPayload>) {
state.user = action.payload.user;
state.token = action.payload.token;
state.isAuthenticated = true;
authStorage.setToken(action.payload.token);
authStorage.setRefreshToken(action.payload.refreshToken);
authStorage.setUser(action.payload.user);
},
logout(state) {
state.user = null;
state.token = null;
state.isAuthenticated = false;
authStorage.clear();
},
hydrateAuth(state) {
const token = authStorage.getToken();
const user = authStorage.getUser();
if (token && user) {
state.token = token;
state.user = user;
state.isAuthenticated = true;
}
},
},
});
export const { loginSuccess, logout, hydrateAuth } = authSlice.actions;
export const authReducer = authSlice.reducer;

View File

@@ -0,0 +1,19 @@
export interface AuthUser {
id: string;
email: string;
username: string;
roles: string[];
permissions: string[];
}
export interface AuthState {
user: AuthUser | null;
token: string | null;
isAuthenticated: boolean;
}
export interface LoginPayload {
user: AuthUser;
token: string;
refreshToken: string;
}

View File

@@ -0,0 +1,28 @@
import type { AuthUser } from '../types/auth.types';
const KEYS = {
token: 'ema-backoffice-auth-token',
refreshToken: 'ema-backoffice-refresh-token',
user: 'ema-backoffice-auth-user',
} as const;
export const authStorage = {
getToken: () => localStorage.getItem(KEYS.token) ?? undefined,
setToken: (token: string) => localStorage.setItem(KEYS.token, token),
removeToken: () => localStorage.removeItem(KEYS.token),
getRefreshToken: () => localStorage.getItem(KEYS.refreshToken) ?? undefined,
setRefreshToken: (token: string) => localStorage.setItem(KEYS.refreshToken, token),
getUser: (): AuthUser | null => {
const raw = localStorage.getItem(KEYS.user);
try {
return raw ? (JSON.parse(raw) as AuthUser) : null;
} catch {
return null;
}
},
setUser: (user: AuthUser) => localStorage.setItem(KEYS.user, JSON.stringify(user)),
clear: () => Object.values(KEYS).forEach((k) => localStorage.removeItem(k)),
};

View File

@@ -0,0 +1,44 @@
import { Grid, Paper, Text, Title, Stack, Group } from '@mantine/core';
import { IconBox, IconUsers, IconActivity } from '@tabler/icons-react';
interface StatCardProps {
label: string;
value: string;
icon: React.ElementType;
color: string;
}
function StatCard({ label, value, icon: Icon, color }: StatCardProps) {
return (
<Paper p="md" shadow="sm" radius="md" withBorder>
<Group justify="space-between">
<Stack gap={4}>
<Text size="sm" c="dimmed">
{label}
</Text>
<Title order={3}>{value}</Title>
</Stack>
<Icon size={32} color={color} />
</Group>
</Paper>
);
}
export function DashboardPage() {
return (
<Stack gap="lg">
<Title order={2}>Dashboard</Title>
<Grid>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Items" value="—" icon={IconBox} color="#2563eb" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Total Users" value="—" icon={IconUsers} color="#16a34a" />
</Grid.Col>
<Grid.Col span={{ base: 12, sm: 4 }}>
<StatCard label="Activity" value="—" icon={IconActivity} color="#d97706" />
</Grid.Col>
</Grid>
</Stack>
);
}

View File

@@ -0,0 +1,39 @@
import { baseApi } from '@ema-platform/api';
export interface Item {
id: string;
name: string;
description: string | null;
status: 'DRAFT' | 'ACTIVE' | 'ARCHIVED';
createdAt: string;
}
export interface PaginatedResponse<T> {
data: T[];
total: number;
page: number;
limit: number;
}
const itemApi = baseApi.injectEndpoints({
endpoints: (builder) => ({
getItems: builder.query<PaginatedResponse<Item>, { page?: number; limit?: number }>({
query: ({ page = 1, limit = 20 } = {}) => ({
url: '/items',
params: { page, limit },
}),
providesTags: ['Api'],
}),
createItem: builder.mutation<Item, { name: string; description?: string }>({
query: (body) => ({ url: '/items', method: 'POST', body }),
invalidatesTags: ['Api'],
}),
deleteItem: builder.mutation<void, string>({
query: (id) => ({ url: `/items/${id}`, method: 'DELETE' }),
invalidatesTags: ['Api'],
}),
}),
overrideExisting: false,
});
export const { useGetItemsQuery, useCreateItemMutation, useDeleteItemMutation } = itemApi;

View File

@@ -0,0 +1,60 @@
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';
const STATUS_COLORS: Record<Item['status'], string> = {
DRAFT: 'gray',
ACTIVE: 'green',
ARCHIVED: 'orange',
};
export function ItemTable() {
const { data, isLoading } = useGetItemsQuery({});
const [deleteItem] = useDeleteItemMutation();
const handleDelete = async (id: string) => {
try {
await deleteItem(id).unwrap();
notify.success('Item deleted');
} catch {
notify.error('Failed to delete item');
}
};
if (isLoading) return <Text>Loading...</Text>;
if (!data?.data.length) return <Text c="dimmed">No items found.</Text>;
return (
<Table striped highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Name</Table.Th>
<Table.Th>Status</Table.Th>
<Table.Th>Created</Table.Th>
<Table.Th />
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{data.data.map((item) => (
<Table.Tr key={item.id}>
<Table.Td>{item.name}</Table.Td>
<Table.Td>
<Badge color={STATUS_COLORS[item.status]}>{item.status}</Badge>
</Table.Td>
<Table.Td>{new Date(item.createdAt).toLocaleDateString()}</Table.Td>
<Table.Td>
<ActionIcon
color="red"
variant="subtle"
onClick={() => handleDelete(item.id)}
>
<IconTrash size={16} />
</ActionIcon>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
);
}

View File

@@ -0,0 +1,13 @@
import { Stack, Title, Paper } from '@mantine/core';
import { ItemTable } from '../components/ItemTable';
export function ItemPage() {
return (
<Stack gap="lg">
<Title order={2}>Items</Title>
<Paper p="md" shadow="sm" radius="md" withBorder>
<ItemTable />
</Paper>
</Stack>
);
}