Add routes placeholders, some of them implemented

- add Login, LoginCompany, Profile, RegisterCompany pages
- add Zustand for handling current user state and token
- add components
- add company, user models
This commit is contained in:
2025-06-09 13:40:09 +04:00
parent bedbb55f88
commit bcf26db8b0
30 changed files with 1395 additions and 28 deletions

47
package-lock.json generated
View File

@@ -11,6 +11,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@heroicons/react": "^2.2.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
@@ -21,6 +22,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.80.6",
"@types/react-router-dom": "^5.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -29,6 +31,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.513.0",
"next-themes": "^0.4.6",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.5",
@@ -1444,6 +1447,15 @@
"dev": true,
"license": "MIT"
},
"node_modules/@heroicons/react": {
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@heroicons/react/-/react-2.2.0.tgz",
"integrity": "sha512-LMcepvRaS9LYHJGsF0zzmgKCUim/X3N/DQKc4jepAXJ7l8QxJ1PmxJzqplF2Z3FE4PqBAIGyJAQ/w4B5dsqbtQ==",
"license": "MIT",
"peerDependencies": {
"react": ">= 16 || ^19.0.0-rc"
}
},
"node_modules/@humanfs/core": {
"version": "0.19.1",
"resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.1.tgz",
@@ -3295,6 +3307,32 @@
"vite": "^5.2.0 || ^6"
}
},
"node_modules/@tanstack/query-core": {
"version": "5.80.6",
"resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.80.6.tgz",
"integrity": "sha512-nl7YxT/TAU+VTf+e2zTkObGTyY8YZBMnbgeA1ee66lIVqzKlYursAII6z5t0e6rXgwUMJSV4dshBTNacNpZHbQ==",
"license": "MIT",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
}
},
"node_modules/@tanstack/react-query": {
"version": "5.80.6",
"resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.80.6.tgz",
"integrity": "sha512-izX+5CnkpON3NQGcEm3/d7LfFQNo9ZpFtX2QsINgCYK9LT2VCIdi8D3bMaMSNhrAJCznRoAkFic76uvLroALBw==",
"license": "MIT",
"dependencies": {
"@tanstack/query-core": "5.80.6"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"peerDependencies": {
"react": "^18 || ^19"
}
},
"node_modules/@tootallnate/once": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz",
@@ -10016,6 +10054,15 @@
"react": "^19.1.0"
}
},
"node_modules/react-icons": {
"version": "5.5.0",
"resolved": "https://registry.npmjs.org/react-icons/-/react-icons-5.5.0.tgz",
"integrity": "sha512-MEFcXdkP3dLo8uumGI5xN3lDFNsRtrjbOEKDLD7yv76v4wpnEq2Lt2qeHaQOr34I/wPN3s3+N08WkQ+CW37Xiw==",
"license": "MIT",
"peerDependencies": {
"react": "*"
}
},
"node_modules/react-is": {
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",

View File

@@ -24,6 +24,7 @@
"dependencies": {
"@electron-toolkit/preload": "^3.0.1",
"@electron-toolkit/utils": "^4.0.0",
"@heroicons/react": "^2.2.0",
"@radix-ui/react-checkbox": "^1.3.2",
"@radix-ui/react-collapsible": "^1.1.11",
"@radix-ui/react-dialog": "^1.1.14",
@@ -34,6 +35,7 @@
"@radix-ui/react-slot": "^1.2.3",
"@radix-ui/react-tooltip": "^1.2.7",
"@tailwindcss/vite": "^4.1.8",
"@tanstack/react-query": "^5.80.6",
"@types/react-router-dom": "^5.3.3",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
@@ -42,6 +44,7 @@
"leaflet": "^1.9.4",
"lucide-react": "^0.513.0",
"next-themes": "^0.4.6",
"react-icons": "^5.5.0",
"react-leaflet": "^5.0.0",
"react-router-dom": "^7.6.2",
"sonner": "^2.0.5",

View File

@@ -1,10 +1,50 @@
import { useEffect } from 'react';
import { toast } from 'sonner';
import { Outlet } from 'react-router-dom';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { Toaster } from '@renderer/components/ui/sonner';
import { SidebarProvider } from '@renderer/components/ui/sidebar';
import AppSidebar from '@renderer/components/AppSidebar';
import { useUserStore } from '@renderer/hooks/userStore';
import { getEmployeeData } from '@renderer/services/user';
const queryClient = new QueryClient();
const Layout = () => {
const token = useUserStore((s) => s.token);
const setToken = useUserStore((s) => s.setToken);
const userData = useUserStore((s) => s.userData);
const setUserData = useUserStore((s) => s.setUserData);
useEffect(() => {
const loader = async () => {
const token = await window.api.getToken();
setToken(token);
if (!token)
return;
const data = await getEmployeeData(token);
if (data instanceof Error) {
toast.error("Произошла ошибка во время загрузки данных", { description: data.message });
return;
}
setUserData(data);
};
if (!token || !userData) {
loader();
}
}, [token, setToken, userData, setUserData]);
return (
<>
<QueryClientProvider client={queryClient}>
<SidebarProvider>
{token && userData && <AppSidebar />}
<Outlet />
</>
<Toaster position="top-right" richColors />
</SidebarProvider>
</QueryClientProvider>
);
};

View File

@@ -0,0 +1,111 @@
import { Link, useLocation } from "react-router-dom";
import { Sidebar,
SidebarContent,
SidebarFooter,
SidebarGroup,
SidebarGroupContent,
SidebarGroupLabel,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
SidebarMenuItem,
SidebarRail } from "@renderer/components/ui/sidebar";
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@renderer/components/ui/collapsible";
import { ChevronRight } from "lucide-react";
import { useUserStore } from "@renderer/hooks/userStore";
import NavUser from "./NavUser";
const AppSidebar = ({ ...props }: React.ComponentProps<typeof Sidebar>) => {
const loc = useLocation();
const userData = useUserStore((s) => s.userData);
if (userData === null)
return null;
return (
<Sidebar {...props}>
<SidebarHeader>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton size="lg" asChild>
<Link to="/">
<div className="flex flex-col gap-0.5 leading-none">
<span className="text-2xl font-semibold">Мой Управдом</span>
</div>
</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarHeader>
<SidebarContent>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel
asChild
className="group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground text-sm"
>
<CollapsibleTrigger>
Главное
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={loc.pathname === "/addresses"}>
<Link to="/addresses">Адреса</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={loc.pathname === "/chats"}>
<Link to="/chats">Чаты</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={loc.pathname === "/notifications"}>
<Link to="/notifications">Уведомления</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
<Collapsible defaultOpen className="group/collapsible">
<SidebarGroup>
<SidebarGroupLabel
asChild
className="group/label text-sidebar-foreground hover:bg-sidebar-accent hover:text-sidebar-accent-foreground text-sm"
>
<CollapsibleTrigger>
Работа с пользователями
<ChevronRight className="ml-auto transition-transform group-data-[state=open]/collapsible:rotate-90" />
</CollapsibleTrigger>
</SidebarGroupLabel>
<CollapsibleContent>
<SidebarGroupContent>
<SidebarMenu>
<SidebarMenuItem>
<SidebarMenuButton asChild isActive={loc.pathname === "/requests"}>
<Link to="/requests">Заявки на присоединения</Link>
</SidebarMenuButton>
</SidebarMenuItem>
</SidebarMenu>
</SidebarGroupContent>
</CollapsibleContent>
</SidebarGroup>
</Collapsible>
</SidebarContent>
<SidebarFooter>
<NavUser user={userData}/>
</SidebarFooter>
<SidebarRail />
</Sidebar>
)
};
export default AppSidebar;

View File

@@ -0,0 +1,18 @@
interface FormErrorProps {
errors: string[],
className?: string
}
const FormError = ({ errors, className }: FormErrorProps) => {
return (
<div className={`${errors.length === 0 && "hidden"} ${className ?? ""}`}>
{
errors.map((error) => (
<p className="text-red-500 text-sm" key={error}>{error}</p>
))
}
</div>
)
};
export default FormError;

View File

@@ -1,9 +0,0 @@
const LoginPage = () => {
return (
<div>
LoginPage
</div>
);
};
export default LoginPage;

View File

@@ -1,7 +0,0 @@
const MainPage = () => {
return (
<div className="text-3xl">Hello, World!</div>
)
};
export default MainPage;

View File

@@ -0,0 +1,71 @@
import { UserEmployeeDto } from "@renderer/models/user/types"
import { SidebarMenu, SidebarMenuButton, SidebarMenuItem } from "./ui/sidebar";
import { DropdownMenu, DropdownMenuContent, DropdownMenuGroup, DropdownMenuItem, DropdownMenuLabel, DropdownMenuSeparator, DropdownMenuTrigger } from "./ui/dropdown-menu";
import { ChevronsUpDown } from "lucide-react";
import { useUserStore } from "@renderer/hooks/userStore";
import { Link, useNavigate } from "react-router-dom";
interface NavUserProps {
user: UserEmployeeDto;
}
const NavUser = ({ user }: NavUserProps ) => {
const logout = useUserStore((s) => s.logout);
const navigate = useNavigate();
const handleLogout = async () => {
await window.api.clearToken();
logout();
navigate("/login");
};
return (
<SidebarMenu>
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton
size="lg"
className="data-[state=open]:bg-sidebar-accent data-[state=open]:text-sidebar-accent-foreground"
>
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.login}</span>
<span className="text-xs">Работник</span>
</div>
<ChevronsUpDown className="ml-auto size-4" />
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent
className="w-(--radix-dropdown-menu-trigger-width) min-w-56 rounded-lg"
side="right"
align="end"
sideOffset={4}
>
<DropdownMenuLabel className="p-0 font-normal">
<div className="flex items-center gap-2 px-1 py-1.5 text-left text-sm">
<div className="grid flex-1 text-left text-sm leading-tight">
<span className="truncate font-medium">{user.login}</span>
</div>
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuGroup>
<DropdownMenuItem asChild>
<Link to="/profile">Профиль</Link>
</DropdownMenuItem>
<DropdownMenuItem asChild>
<Link to="/notifications">Уведомления</Link>
</DropdownMenuItem>
</DropdownMenuGroup>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleLogout} variant="destructive">
Выйти из аккаунта
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
</SidebarMenu>
)
};
export default NavUser;

View File

@@ -0,0 +1,18 @@
import { UserEmployeeDto } from '@renderer/models/user/types';
import { create } from 'zustand';
type UserStore = {
token: string | null;
userData: UserEmployeeDto | null;
setToken: (token: string | null) => void;
setUserData: (userData: UserEmployeeDto | null) => void;
logout: () => void;
};
export const useUserStore = create<UserStore>((set) => ({
token: null,
userData: null,
setToken: (token) => set({ token }),
setUserData: (userData) => set({ userData }),
logout: () => set({ token: null, userData: null }),
}));

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -2,13 +2,19 @@ import './assets/tailwind.css';
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import MainPage from './components/MainPage';
import Layout from './Layout';
import ErrorPage from './components/ErrorPage';
import { createHashRouter, RouterProvider } from 'react-router-dom';
import LoginPage from './components/LoginPage';
import { createBrowserRouter, RouterProvider } from 'react-router-dom';
const router = createHashRouter(
import Layout from '@renderer/Layout';
import MainPage from '@renderer/pages/MainPage';
import ErrorPage from '@renderer/pages/ErrorPage';
import LoginPage from '@renderer/pages/login/LoginPage';
import RegisterPage from '@renderer/pages/register/RegisterPage';
import ProfilePage from '@renderer/pages/profile/ProfilePage';
import LoginCompanyPage from '@renderer/pages/company/login/LoginCompanyPage';
const router = createBrowserRouter(
[
{
element: <Layout />,
@@ -21,14 +27,50 @@ const router = createHashRouter(
{
path: '/login',
element: <LoginPage />,
}
},
{
path: '/register',
element: <RegisterPage />,
},
{
path: '/profile',
element: <ProfilePage />,
},
{
path: '/requests',
},
{
path: '/notifications',
},
{
path: '/addresses',
},
{
path: '/address/:id',
},
{
path: '/chats',
},
{
path: '/chat/:id',
},
{
path: '/company/login',
element: <LoginCompanyPage />,
},
{
path: '/company/employees',
},
{
path: '/company/users',
},
]
}
],
{
basename: '/'
}
)
);
createRoot(document.getElementById('root')!).render(

View File

@@ -0,0 +1,48 @@
import { z } from "zod/v4";
export const CompanyRegisterDto = z.object({
login: z.string()
.regex(/^[a-zA-Zа-яА-ЯёЁ0-9_]+$/, {message: "Поле должно содержать буквы, цифры и нижнее подчеркивание"})
.max(24, { message: "Логин слишком длинный" })
.nonempty(),
password: z.string()
.nonempty(),
name: z.string()
.regex(/^[a-zA-Zа-яА-ЯёЁ0-9 -]+$/, { message: "Поле должно содержать только буквы, цифры, дефис и пробел"})
.max(24, { message: "Название слишком длинное" })
.nonempty(),
phone: z.string()
.regex(/^\+\d{1,15}$/, { message: "Недопустимый формат номера телефона" })
.max(15, { message: "Номер слишком длинный" })
.nonempty(),
workingHours: z.string()
.regex(/^(?:[01]\d|2[0-3]):[0-5]\d-24:00$|^(?:[01]\d|2[0-3]):[0-5]\d-(?:[01]\d|2[0-3]):[0-5]\d$/)
.nonempty(),
workingDays: z.string()
.regex(/^(пн|вт|ср|чт|пт|сб|вс)-(пн|вт|ср|чт|пт|сб|вс)$/)
.nonempty()
});
export const CompanyLoginDto = z.object({
login: z.string()
.regex(/^[a-zA-Zа-яА-ЯёЁ0-9_]+$/, {message: "Поле должно содержать буквы, цифры и нижнее подчеркивание"})
.max(24, { message: "Логин слишком длинный" })
.nonempty(),
password: z.string()
.nonempty(),
});
export const CompanyDto = z.object({
id: z.number(),
login: z.string(),
name: z.string(),
phone: z.string(),
workingHours: z.string(),
workingDays: z.string(),
longitude: z.number(),
latitude: z.number(),
locationCity: z.string(),
locationCityDistrict: z.string(),
locationRoad: z.string(),
locationHouseNumber: z.string(),
});

View File

@@ -0,0 +1,6 @@
import { z } from "zod/v4";
import { CompanyDto, CompanyLoginDto, CompanyRegisterDto } from "@renderer/models/company/dtos";
export type CompanyRegisterDto = z.infer<typeof CompanyRegisterDto>;
export type CompanyLoginDto = z.infer<typeof CompanyLoginDto>;
export type CompanyDto = z.infer<typeof CompanyDto>;

View File

@@ -0,0 +1,40 @@
import { z } from "zod/v4";
export const UserEmployeeDto = z.object({
id: z.number(),
login: z.string(),
firstName: z.string(),
lastName: z.string(),
middleName: z.string(),
creationDate: z.date(),
phone: z.string(),
companyId: z.number(),
});
export const UserLoginDto = z.object({
login: z.string()
.regex(/^[a-zA-Zа-яА-ЯёЁ0-9_]+$/, { message: "Поле должно содержать буквы, цифры и нижнее подчеркивание" })
.max(24, { message: "Логин слишком длинный" })
.nonempty(),
password: z.string()
.nonempty(),
});
export const EmployeeUpdateDto = z.object({
firstName: z.string()
.regex(/^[а-яА-ЯёЁ-]+$/, { message: "Поле должно содержать только кириллические буквы, а также дефис" })
.max(24, { message: "Имя слишком длинное" })
.optional(),
lastName: z.string()
.regex(/^[а-яА-ЯёЁ-]+$/, { message: "Поле должно содержать только кириллические буквы, а также дефис" })
.max(24, { message: "Фамилия слишком длинная" })
.optional(),
middleName: z.string()
.regex(/^[а-яА-ЯёЁ-]+$/, { message: "Поле должно содержать только кириллические буквы, а также дефис" })
.max(24, { message: "Отчество слишком длинное" })
.optional(),
phone: z.string()
.regex(/^\+\d{1,15}$/, { message: "Недопустимый формат номера телефона" })
.max(15, { message: "Номер слишком длинный" })
.optional(),
});

View File

@@ -0,0 +1,6 @@
import z from "zod/v4";
import { UserEmployeeDto, UserLoginDto, EmployeeUpdateDto } from "./dtos";
export type UserEmployeeDto = z.infer<typeof UserEmployeeDto>;
export type UserLoginDto = z.infer<typeof UserLoginDto>;
export type EmployeeUpdateDto = z.infer<typeof EmployeeUpdateDto>;

View File

@@ -1,7 +1,7 @@
const ErrorPage = () => {
return (
<div>
Произошла ошибка
Страница не найдена
</div>
)
};

View File

@@ -0,0 +1,23 @@
import { useEffect } from "react";
import { useNavigate } from "react-router-dom";
import { useUserStore } from "@renderer/hooks/userStore";
const MainPage = () => {
const navigate = useNavigate();
const token = useUserStore((s) => s.token);
useEffect(() => {
if (!token) {
navigate('/login');
}
}, []);
return (
<div className="w-1/3 space-y-2">
<div className="text-3xl">Hello, World!</div>
</div>
)
};
export default MainPage;

View File

@@ -0,0 +1,85 @@
import { useEffect, useState } from "react";
import { toast } from "sonner";
import { Link, useNavigate } from "react-router-dom";
import { Label } from "@renderer/components/ui/label";
import { Input } from "@renderer/components/ui/input";
import { Button } from "@renderer/components/ui/button";
import FormError from "@renderer/components/FormErrors";
import { CompanyLoginDto } from "@renderer/models/company/dtos";
import { loginAsCompany } from "@renderer/services/auth";
import { useUserStore } from "@renderer/hooks/userStore";
const LoginCompanyPage = () => {
const navigate = useNavigate();
const token = useUserStore((s) => s.token);
const [login, setLogin] = useState<string>("");
const [loginError, setLoginError] = useState<string[]>([]);
useEffect(() => {
if (token)
navigate('/');
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const body = CompanyLoginDto.safeParse(
{
login: formData.get('login') as string,
password: formData.get('password') as string
}
);
if (body.success) {
const res = await loginAsCompany(body.data);
if (res instanceof Error) {
toast.error("Произошла ошибка во время входа в аккаунт УК", { description: res.message });
return;
}
await window.api.setToken(res.access_token);
toast.success("Вы успешно вошли в аккаунт УК");
navigate('/');
}
else {
toast.error("Данные введены неверно");
}
};
const handleInput = (e: React.ChangeEvent<HTMLInputElement>) => {
setLogin(e.target.value);
if (e.currentTarget.value === "") {
setLoginError([]);
return;
}
const p = CompanyLoginDto.shape.login.safeParse(e.target.value);
if (!p.success) {
setLoginError(p.error.issues.map(i => i.message));
}
};
return (
<div className="flex justify-center items-center flex-grow">
<form method="post" className="w-1/3 xl:w-1/4 space-y-4" onSubmit={handleSubmit}>
<h2 className="text-4xl font-semibold text-center mb-6">Вход в аккаунт УК</h2>
<div className="space-y-2">
<Label htmlFor="login">Логин</Label>
<Input value={login} onChange={handleInput} name="login" type="text" placeholder="Логин" required/>
<FormError errors={loginError} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<Input name="password" type="password" placeholder="Пароль" required/>
</div>
<div className="flex space-x-2 justify-end">
<Button type="submit" disabled={loginError.length > 0}>Войти</Button>
<Button variant="outline" type="button" asChild><Link to="/login">Войти как работник</Link></Button>
<Button variant="outline" type="button" asChild><Link to="/register">Регистрация УК</Link></Button>
</div>
</form>
</div>
);
};
export default LoginCompanyPage;

View File

@@ -0,0 +1,70 @@
import { useEffect } from "react";
import { toast } from "sonner";
import { Link, useNavigate } from "react-router-dom";
import { Label } from "@renderer/components/ui/label";
import { Input } from "@renderer/components/ui/input";
import { Button } from "@renderer/components/ui/button";
import { UserLoginDto } from "@renderer/models/user/dtos";
import { loginAsEmployee } from "@renderer/services/auth";
import { useUserStore } from "@renderer/hooks/userStore";
const LoginPage = () => {
const navigate = useNavigate();
const token = useUserStore((s) => s.token);
const setToken = useUserStore((s) => s.setToken);
useEffect(() => {
if (token)
navigate('/');
}, []);
const handleSubmit = async (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
const body = UserLoginDto.safeParse(
{
login: formData.get('login') as string,
password: formData.get('password') as string
}
);
if (body.success) {
const res = await loginAsEmployee(body.data);
if (res instanceof Error) {
toast.error("Произошла ошибка во время входа в аккаунт", { description: res.message });
return;
}
await window.api.setToken(res.access_token);
setToken(res.access_token);
toast.success("Вы успешно вошли в аккаунт");
navigate('/');
}
else {
toast.error("Данные введены неверно");
}
}
return (
<div className="flex justify-center items-center flex-grow">
<form method="post" className="w-1/3 xl:w-1/4 space-y-4" onSubmit={handleSubmit}>
<h2 className="text-4xl font-semibold text-center mb-6">Вход в аккаунт</h2>
<div className="space-y-2">
<Label htmlFor="login">Логин</Label>
<Input name="login" type="text" placeholder="Логин" required/>
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label>
<Input name="password" type="password" placeholder="Пароль" required/>
</div>
<div className="flex space-x-2 justify-end">
<Button type="submit">Войти</Button>
<Button variant="outline" type="button" asChild><Link to="/company/login">Войти в аккаунт УК</Link></Button>
</div>
</form>
</div>
);
};
export default LoginPage;

View File

@@ -0,0 +1,65 @@
import { EmployeeUpdateDto } from "@renderer/models/user/dtos";
export type State = {
values: {
firstName: string;
lastName: string;
middleName: string;
phone: string;
};
errors: {
firstName: string[];
lastName: string[];
middleName: string[];
phone: string[];
};
};
type Action =
| { type: "UPDATE_FIELD"; field: keyof State["values"]; value: string }
| { type: "VALIDATE_FIELD"; field: keyof State["values"] }
| { type: "RESET_ERRORS"; field: keyof State["values"] };
function employeeReducer(state: State, action: Action): State {
switch (action.type) {
case "RESET_ERRORS":
return {
...state,
errors: {
...state.errors,
[action.field]: [],
},
}
case "UPDATE_FIELD":
return {
...state,
values: {
...state.values,
[action.field]: action.value,
},
};
case "VALIDATE_FIELD": {
let fieldErrors: string[] = [];
const schema = EmployeeUpdateDto.shape[action.field];
if (schema) {
const res = schema.safeParse(state.values[action.field]);
if (!res.success) {
fieldErrors = res.error.issues.map((issue) => issue.message);
}
}
return {
...state,
errors: {
...state.errors,
[action.field]: fieldErrors,
},
};
}
default:
return state;
}
}
export { employeeReducer };

View File

@@ -0,0 +1,224 @@
import { useEffect, useReducer, useRef, useState } from "react";
import { toast } from "sonner";
import { HiMiniPhone, HiCalendarDays, HiClock, HiBuildingOffice } from "react-icons/hi2";
import { useNavigate } from "react-router-dom";
import { useQuery } from "@tanstack/react-query";
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from "@renderer/components/ui/card";
import { Input } from "@renderer/components/ui/input";
import { Label } from "@renderer/components/ui/label";
import { Button } from "@renderer/components/ui/button";
import { Skeleton } from "@renderer/components/ui/skeleton";
import FormError from "@renderer/components/FormErrors";
import { useUserStore } from "@renderer/hooks/userStore";
import { EmployeeUpdateDto } from "@renderer/models/user/dtos";
import { getCompanyData } from "@renderer/services/company";
import { updateEmployeeData } from "@renderer/services/user";
import { employeeReducer, State as FormState } from "./EmployeeReducer";
const ProfilePage = () => {
const token = useUserStore((s) => s.token);
const userData = useUserStore((s) => s.userData);
const setUserData = useUserStore((s) => s.setUserData);
const [edit, setEdit] = useState(false);
const ref = useRef<HTMLFormElement>(null);
const navigate = useNavigate();
useEffect(() => {
if (!token) {
navigate('/login');
}
}, []);
const { data: companyData, isLoading } = useQuery(
{
queryKey: ['companyData', userData?.companyId],
queryFn: () => {
if (!userData?.companyId || !token)
return null;
return getCompanyData(token, userData?.companyId);
},
retry: 1,
enabled: !!userData?.companyId && !!token
},
);
useEffect(() => {
if (!isLoading) {
if (!companyData) {
toast.error("Произошла ошибка во время загрузки данных УК");
return;
}
if (companyData instanceof Error) {
toast.error("Произошла ошибка во время загрузки данных УК", { description: companyData.message });
}
}
}, [companyData]);
const initState : FormState = {
values: {
firstName: userData?.firstName ?? "",
lastName: userData?.lastName ?? "",
middleName: userData?.middleName ?? "",
phone: userData?.phone ?? "",
},
errors: {
firstName: [],
lastName: [],
middleName: [],
phone: [],
}
};
const [state, dispatch] = useReducer(employeeReducer, initState)
const handleInput = (field: keyof FormState["values"]) => (e: React.ChangeEvent<HTMLInputElement >) => {
if (e.currentTarget.value === "") {
dispatch({ type: "UPDATE_FIELD", field, value: e.target.value });
dispatch({ type: "RESET_ERRORS", field });
return;
}
dispatch({ type: "UPDATE_FIELD", field, value: e.target.value });
dispatch({ type: "VALIDATE_FIELD", field });
};
const handleEdit = () => {
setEdit(true);
};
const handleCancel = () => {
setEdit(false);
dispatch({type: "UPDATE_FIELD", field: "firstName", value: initState.values.firstName});
dispatch({type: "UPDATE_FIELD", field: "lastName", value: initState.values.lastName});
dispatch({type: "UPDATE_FIELD", field: "middleName", value: initState.values.middleName});
dispatch({type: "UPDATE_FIELD", field: "phone", value: initState.values.phone});
dispatch({type: "RESET_ERRORS", field: "firstName"});
dispatch({type: "RESET_ERRORS", field: "lastName"});
dispatch({type: "RESET_ERRORS", field: "middleName"});
dispatch({type: "RESET_ERRORS", field: "phone"});
};
const handleSubmit = async () => {
if (!ref.current)
return;
const formData = new FormData(ref.current);
const body = EmployeeUpdateDto.safeParse({
firstName: formData.get('firstName') as string === "" ? null : formData.get('firstName') as string,
lastName: formData.get('lastName') as string === "" ? null : formData.get('lastName') as string,
middleName: formData.get('middleName') as string === "" ? null : formData.get('middleName') as string,
phone: formData.get('phone') as string === "" ? null : formData.get('phone') as string,
});
if (!body.success) {
toast.error("Данные введены неверно");
return;
}
const data = await updateEmployeeData(token ?? "", body.data);
if (data instanceof Error) {
toast.error("Произошла ошибка во время обновления данных", { description: data.message });
return;
}
const updated = {
...userData!,
...(body.data.firstName && {firstName: body.data.firstName}),
...(body.data.lastName && {lastName: body.data.lastName}),
...(body.data.middleName && {middleName: body.data.middleName}),
...(body.data.phone && {phone: body.data.phone}),
}
setUserData(updated);
setEdit(false);
};
const doesHaveErrors = Object.values(state.errors).some((errs) => errs.length > 0);
const doesHaveValues = () => {
return (
state.values.firstName !== "" ||
state.values.lastName !== "" ||
state.values.phone !== ""
);
}
return (
<div className="w-full px-16 mt-12 space-y-4">
{ !(companyData instanceof Error) &&
<Card>
<CardHeader>
<CardTitle>
Ваша компания
</CardTitle>
</CardHeader>
<CardContent>
{
isLoading ? <Skeleton className="h-38"/> :
<>
<div className="flex items-center space-x-2 mb-6">
<p className="font-semibold text-2xl">{companyData?.name}</p>
</div>
<CardDescription className="space-y-1">
<div className="flex items-center space-x-2">
<HiMiniPhone /><p>{companyData?.phone}</p>
</div>
<div className="flex items-center space-x-2">
<HiCalendarDays /><p>{companyData?.workingDays}</p>
</div>
<div className="flex items-center space-x-2">
<HiClock /><p>{companyData?.workingHours}</p>
</div>
<div className="flex items-center space-x-2">
<HiBuildingOffice /><p>{companyData?.locationCity}, {companyData?.locationRoad}, {companyData?.locationHouseNumber}</p>
</div>
</CardDescription>
</>
}
</CardContent>
</Card>
}
<Card>
<CardHeader>
<CardTitle>Профиль</CardTitle>
</CardHeader>
<CardContent>
<CardDescription className="mb-6">Аккаунт создан {userData?.creationDate.toLocaleDateString()}</CardDescription>
<form method="post" ref={ref} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="firstName">Имя</Label>
<Input name="firstName" disabled={!edit} value={state.values.firstName} placeholder="Имя" onChange={handleInput("firstName")} required/>
<FormError errors={state.errors.firstName} />
</div>
<div className="space-y-2">
<Label htmlFor="lastName">Фамилия</Label>
<Input name="lastName" disabled={!edit} value={state.values.lastName} placeholder="Фамилия" onChange={handleInput("lastName")} required/>
<FormError errors={state.errors.lastName} />
</div>
<div className="space-y-2">
<Label htmlFor="middleName">Отчество</Label>
<Input name="middleName" disabled={!edit} value={state.values.middleName} placeholder="Отчество (необязательно)" onChange={handleInput("middleName")}/>
<FormError errors={state.errors.middleName} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Телефон</Label>
<Input name="phone" disabled={!edit} value={state.values.phone} placeholder="Телефон" onChange={handleInput("phone")} required/>
<FormError errors={state.errors.phone} />
</div>
</form>
</CardContent>
<CardFooter className="space-x-2">
{edit ?
<>
<Button onClick={handleSubmit} disabled={doesHaveErrors || !doesHaveValues()}>Сохранить</Button>
<Button variant="outline" onClick={handleCancel}>Отменить</Button>
</>
:
<Button onClick={handleEdit}>Изменить</Button>}
</CardFooter>
</Card>
</div>
)
};
export default ProfilePage;

View File

@@ -0,0 +1,175 @@
import { useEffect, useReducer, useState } from "react";
import { Form, Link, useNavigate } from "react-router-dom";
import { Label } from "@renderer/components/ui/label";
import { Input } from "@renderer/components/ui/input";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@renderer/components/ui/select";
import { Button } from "@renderer/components/ui/button";
import FormError from "@renderer/components/FormErrors";
import { initialState, inputsReducer, State as FormState } from "./RegisterReducer";
import { useUserStore } from "@renderer/hooks/userStore";
const days = ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', 'вс'];
const RegisterPage = () => {
const navigate = useNavigate();
const token = useUserStore((s) => s.token);
const [state, dispatch] = useReducer(inputsReducer, initialState);
const [workingHoursFrom, setWorkingHoursFrom] = useState<string>('');
const [workingHoursTo, setWorkingHoursTo] = useState<string>('');
const [workingHoursError, setWorkingHoursError] = useState<string>('');
const [workingDaysFrom, setWorkingDaysFrom] = useState<string>('');
const [workingDaysTo, setWorkingDaysTo] = useState<string>('');
const [workingDaysError, setWorkingDaysError] = useState<string>('');
useEffect(() => {
if (token)
navigate('/');
}, []);
const handleInput = (field: keyof FormState["values"]) => (e: React.ChangeEvent<HTMLInputElement >) => {
if (e.currentTarget.value === "") {
dispatch({ type: "RESET_ERRORS", field });
return;
}
dispatch({ type: "UPDATE_FIELD", field, value: e.target.value });
dispatch({ type: "VALIDATE_FIELD", field });
};
const validateDays = (from, to) => {
if (from !== '' && to !== '') {
if (from === to)
setWorkingDaysError("Рабочие дни должны быть разные");
else if (days.indexOf(from) > days.indexOf(to))
setWorkingDaysError('Начало не может быть позже конца');
else
setWorkingDaysError('');
}
else {
setWorkingDaysError('');
}
}
const validateHours = (from, to) => {
if (from !== '' && to !== '') {
if (from === to)
setWorkingHoursError("Рабочие часы должны быть разные");
else if (from > to)
setWorkingHoursError('Начало не может быть позже конца');
else
setWorkingHoursError('');
}
else {
setWorkingHoursError('');
}
}
const handleHoursFromChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkingHoursFrom(e.target.value);
validateHours(e.target.value, workingHoursTo);
};
const handleHoursToChange = (e: React.ChangeEvent<HTMLInputElement>) => {
setWorkingHoursTo(e.target.value);
validateHours(workingHoursFrom, e.target.value);
};
const handleDaysFromChange = (val: string) => {
setWorkingDaysFrom(val);
validateDays(val, workingDaysTo);
};
const handleDaysToChange = (val: string) => {
setWorkingDaysTo(val);
validateDays(workingDaysFrom, val);
};
const hasErrors = () => state.errors.login.length > 0 || state.errors.name.length > 0 || state.errors.phone.length > 0 || workingHoursError.length > 0 || workingDaysError.length > 0;
return (
<div className="flex justify-center items-center flex-grow">
<Form method="post" className="w-3/4 md:w-2/4 xl:w-1/4 space-y-4">
<h2 className="text-4xl font-semibold text-center mb-6">Регистрация УК</h2>
<div className="space-y-2">
<Label htmlFor="login">Логин</Label >
<Input name="login" placeholder="Логин" onChange={handleInput("login")} required/>
<FormError errors={state.errors.login} />
</div>
<div className="space-y-2">
<Label htmlFor="password">Пароль</Label >
<Input name="password" placeholder="Пароль" type="password" required/>
</div>
<div className="space-y-2">
<Label htmlFor="name">Название компании</Label >
<Input name="name" placeholder="Название" onChange={handleInput("name")} required/>
<FormError errors={state.errors.name} />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Номер телефона</Label >
<Input name="phone" placeholder="Телефон" onChange={handleInput("phone")} required/>
<FormError errors={state.errors.phone} />
</div>
<div className="space-y-2">
<div className="flex space-x-2">
<div className="space-y-2 w-full">
<Label htmlFor="working_hours_from">Рабочие часы (с)</Label >
<Input value={workingHoursFrom} onChange={handleHoursFromChange}step="60" name="working_hours_from" className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" type="time" required/>
</div>
<div className="space-y-2 w-full">
<Label htmlFor="working_hours_to">Рабочие часы (до)</Label >
<Input value={workingHoursTo} onChange={handleHoursToChange} disabled={workingHoursFrom === ''} step="60" name="working_hours_to" className="bg-background appearance-none [&::-webkit-calendar-picker-indicator]:hidden [&::-webkit-calendar-picker-indicator]:appearance-none" type="time"required/>
</div>
</div>
<FormError errors={[workingHoursError]}/>
</div>
<div className="space-y-2">
<div className="flex space-x-2">
<div className="space-y-2 w-full">
<Label>Рабочие дни (с)</Label>
<Select name="working_days_from" required value={workingDaysFrom} onValueChange={handleDaysFromChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="пн">Понедельник</SelectItem>
<SelectItem value="вт">Вторник</SelectItem>
<SelectItem value="ср">Среда</SelectItem>
<SelectItem value="чт">Четверг</SelectItem>
<SelectItem value="пт">Пятница</SelectItem>
<SelectItem value="сб">Суббота</SelectItem>
<SelectItem value="вс">Воскресенье</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2 w-full">
<Label>Рабочие дни (с)</Label>
<Select name="working_days_to" required value={workingDaysTo} onValueChange={handleDaysToChange}>
<SelectTrigger className="w-full">
<SelectValue placeholder="Выберите" />
</SelectTrigger>
<SelectContent>
<SelectItem value="пн">Понедельник</SelectItem>
<SelectItem value="вт">Вторник</SelectItem>
<SelectItem value="ср">Среда</SelectItem>
<SelectItem value="чт">Четверг</SelectItem>
<SelectItem value="пт">Пятница</SelectItem>
<SelectItem value="сб">Суббота</SelectItem>
<SelectItem value="вс">Воскресенье</SelectItem>
</SelectContent>
</Select>
</div>
</div>
<FormError errors={[workingDaysError]}/>
</div>
<div className="flex space-x-2 justify-end">
<Button type="submit" disabled={hasErrors()}>Создать аккаунт УК</Button>
<Button type="button" variant="outline" asChild><Link to="/login-company">Войти в аккаунт УК</Link></Button>
</div>
</Form>
</div>
);
};
export default RegisterPage;

View File

@@ -0,0 +1,76 @@
import { CompanyRegisterDto } from "@renderer/models/company/dtos";
export type State = {
values: {
login: string;
name: string;
phone: string;
};
errors: {
login: string[];
name: string[];
phone: string[];
};
};
type Action =
| { type: "UPDATE_FIELD"; field: keyof State["values"]; value: string }
| { type: "VALIDATE_FIELD"; field: keyof State["values"] }
| { type: "RESET_ERRORS"; field: keyof State["values"] };
const initialState: State = {
values: {
login: "",
name: "",
phone: "",
},
errors: {
login: [],
name: [],
phone: [],
},
};
function inputsReducer(state: State, action: Action): State {
switch (action.type) {
case "RESET_ERRORS":
return {
...state,
errors: {
...state.errors,
[action.field]: [],
},
}
case "UPDATE_FIELD":
return {
...state,
values: {
...state.values,
[action.field]: action.value,
},
};
case "VALIDATE_FIELD": {
let fieldErrors: string[] = [];
const schema = CompanyRegisterDto.shape[action.field];
if (schema) {
const res = schema.safeParse(state.values[action.field]);
if (!res.success) {
fieldErrors = res.error.issues.map((issue) => issue.message);
}
}
return {
...state,
errors: {
...state.errors,
[action.field]: fieldErrors,
},
};
}
default:
return state;
}
}
export { inputsReducer, initialState };

View File

View File

@@ -0,0 +1,61 @@
import requestWrapper from "@renderer/utils/requestWrapper";
import { BASE_URL } from "./utils";
import { CompanyLoginDto } from "@renderer/models/company/types";
const API_URL = `${BASE_URL}`;
export async function loginAsCompany(body: CompanyLoginDto): Promise<Error | { access_token: string, token_type: string }> {
const data = await requestWrapper(`${API_URL}/login_as_company`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
username: body.login,
password: body.password,
grant_type: "password",
scope: "",
client_id: "",
client_secret: ""
}).toString()
});
if (data instanceof Error) {
return data;
}
if (data.detail) {
if (data.detail.error === "InvalidCredentials") {
return new Error("Неправильный логин или пароль");
}
if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных");
}
}
return data;
}
export async function loginAsEmployee(body: any) {
const data = await requestWrapper(`${API_URL}/login_as_employee`, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded",
},
body: new URLSearchParams({
username: body.login,
password: body.password,
grant_type: "password",
scope: "",
client_id: "",
client_secret: ""
}).toString()
});
if (data.detail) {
if (data.detail.error === "InvalidCredentials") {
return new Error("Неправильный логин или пароль");
}
if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных");
}
}
return data;
}

View File

@@ -0,0 +1,43 @@
import requestWrapper from "@renderer/utils/requestWrapper";
import { BASE_URL } from "./utils";
import { CompanyDto } from "@renderer/models/company/types";
const API_URL = `${BASE_URL}/company`;
export async function getCompanyData(token: string, company_id: number): Promise<CompanyDto | Error> {
const params = new URLSearchParams({company_id: String(company_id)}).toString();
const data = await requestWrapper(`${API_URL}/company_data?${params}`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
});
if (data instanceof Error) {
return data;
}
if (data?.detail) {
if (data.detail.error === "NotExists" && data.detail.class_name === "Company") {
return new Error("Компания не найдена");
}
if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных");
}
return new Error(data.detail);
}
return {
id: data.id,
name: data.name,
login: data.login,
phone: data.phone,
workingHours: data.working_hours,
workingDays: data.working_days,
longitude: data.longitude,
latitude: data.latitude,
locationCity: data.location_city,
locationCityDistrict: data.location_city_district,
locationRoad: data.location_road,
locationHouseNumber: data.location_house_number
};
}

View File

@@ -0,0 +1,65 @@
import requestWrapper from "@renderer/utils/requestWrapper";
import { BASE_URL } from "./utils";
import { UserEmployeeDto, EmployeeUpdateDto } from "@renderer/models/user/types";
const API_URL = `${BASE_URL}/user`;
export async function getEmployeeData(token: string): Promise<UserEmployeeDto | Error>{
const data = await requestWrapper(`${API_URL}/profile_data`, {
method: "GET",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
}
});
if (data instanceof Error) {
return data;
}
if (data.detail) {
if (data.detail.error === "NotExists" && data.detail.class_name === "User") {
return new Error("Пользователь не найден");
}
if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных");
}
return new Error(data.detail);
}
return {
id: data.id,
login: data.login,
firstName: data.first_name,
lastName: data.last_name,
middleName: data.middle_name,
creationDate: new Date(data.creation_date),
phone: data.phone,
companyId: data.linked_company_id
};
}
export async function updateEmployeeData(token: string, body: EmployeeUpdateDto) {
const data = await requestWrapper(`${API_URL}/update/user_data`, {
method: "PUT",
headers: {
"Content-Type": "application/json",
"Authorization": `Bearer ${token}`
},
body: JSON.stringify({
first_name: body.firstName,
last_name: body.lastName,
middle_name: body.middleName,
phone: body.phone
})
});
if (data instanceof Error) {
return data;
}
if (data.detail) {
if (data.detail.error === "DatabaseError") {
return new Error("Произошла ошибка в базе данных");
}
return new Error(data.detail);
}
return true;
}

View File

@@ -0,0 +1 @@
export const BASE_URL = import.meta.env.VITE_API_URL;

View File

@@ -0,0 +1,19 @@
export default async function requestWrapper(url: string, init?: RequestInit) {
try {
const res = await fetch(url, init);
const data = await res.json();
return data;
}
catch (e: any) {
if (e.cause) {
if (e.cause.code == "ECONNREFUSED") {
return new Error("Сервер недоступен");
}
if (e.cause.code == "ECONNRESET") {
return new Error("Сервер недоступен");
}
}
return new Error(e.message);
}
}

View File

@@ -0,0 +1,20 @@
import { jwtDecode } from "jwt-decode";
export const EMPLOYEE = "employee";
export const COMPANY = "company";
type JwtPayload = {
id: number,
login: string,
company_id: number | null,
is_company: boolean | null,
is_employee: boolean | null,
}
export function getRole(token: string): string | null {
const decoded = jwtDecode<JwtPayload>(token);
if (!decoded) return null;
if (decoded.is_company)
return COMPANY;
return EMPLOYEE;
}