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:
47
package-lock.json
generated
47
package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
111
src/renderer/src/components/AppSidebar.tsx
Normal file
111
src/renderer/src/components/AppSidebar.tsx
Normal 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;
|
||||
18
src/renderer/src/components/FormErrors.tsx
Normal file
18
src/renderer/src/components/FormErrors.tsx
Normal 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;
|
||||
@@ -1,9 +0,0 @@
|
||||
const LoginPage = () => {
|
||||
return (
|
||||
<div>
|
||||
LoginPage
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginPage;
|
||||
@@ -1,7 +0,0 @@
|
||||
const MainPage = () => {
|
||||
return (
|
||||
<div className="text-3xl">Hello, World!</div>
|
||||
)
|
||||
};
|
||||
|
||||
export default MainPage;
|
||||
71
src/renderer/src/components/NavUser.tsx
Normal file
71
src/renderer/src/components/NavUser.tsx
Normal 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;
|
||||
18
src/renderer/src/hooks/userStore.ts
Normal file
18
src/renderer/src/hooks/userStore.ts
Normal 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 }),
|
||||
}));
|
||||
6
src/renderer/src/lib/utils.ts
Normal file
6
src/renderer/src/lib/utils.ts
Normal 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))
|
||||
}
|
||||
@@ -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(
|
||||
|
||||
48
src/renderer/src/models/company/dtos.ts
Normal file
48
src/renderer/src/models/company/dtos.ts
Normal 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(),
|
||||
});
|
||||
6
src/renderer/src/models/company/types.ts
Normal file
6
src/renderer/src/models/company/types.ts
Normal 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>;
|
||||
40
src/renderer/src/models/user/dtos.ts
Normal file
40
src/renderer/src/models/user/dtos.ts
Normal 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(),
|
||||
});
|
||||
6
src/renderer/src/models/user/types.ts
Normal file
6
src/renderer/src/models/user/types.ts
Normal 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>;
|
||||
@@ -1,7 +1,7 @@
|
||||
const ErrorPage = () => {
|
||||
return (
|
||||
<div>
|
||||
Произошла ошибка
|
||||
Страница не найдена
|
||||
</div>
|
||||
)
|
||||
};
|
||||
23
src/renderer/src/pages/MainPage.tsx
Normal file
23
src/renderer/src/pages/MainPage.tsx
Normal 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;
|
||||
85
src/renderer/src/pages/company/login/LoginCompanyPage.tsx
Normal file
85
src/renderer/src/pages/company/login/LoginCompanyPage.tsx
Normal 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;
|
||||
70
src/renderer/src/pages/login/LoginPage.tsx
Normal file
70
src/renderer/src/pages/login/LoginPage.tsx
Normal 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;
|
||||
65
src/renderer/src/pages/profile/EmployeeReducer.ts
Normal file
65
src/renderer/src/pages/profile/EmployeeReducer.ts
Normal 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 };
|
||||
224
src/renderer/src/pages/profile/ProfilePage.tsx
Normal file
224
src/renderer/src/pages/profile/ProfilePage.tsx
Normal 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;
|
||||
175
src/renderer/src/pages/register/RegisterPage.tsx
Normal file
175
src/renderer/src/pages/register/RegisterPage.tsx
Normal 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;
|
||||
76
src/renderer/src/pages/register/RegisterReducer.ts
Normal file
76
src/renderer/src/pages/register/RegisterReducer.ts
Normal 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 };
|
||||
0
src/renderer/src/services/address.ts
Normal file
0
src/renderer/src/services/address.ts
Normal file
61
src/renderer/src/services/auth.ts
Normal file
61
src/renderer/src/services/auth.ts
Normal 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;
|
||||
}
|
||||
43
src/renderer/src/services/company.ts
Normal file
43
src/renderer/src/services/company.ts
Normal 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
|
||||
};
|
||||
}
|
||||
65
src/renderer/src/services/user.ts
Normal file
65
src/renderer/src/services/user.ts
Normal 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;
|
||||
}
|
||||
1
src/renderer/src/services/utils.ts
Normal file
1
src/renderer/src/services/utils.ts
Normal file
@@ -0,0 +1 @@
|
||||
export const BASE_URL = import.meta.env.VITE_API_URL;
|
||||
19
src/renderer/src/utils/requestWrapper.ts
Normal file
19
src/renderer/src/utils/requestWrapper.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
20
src/renderer/src/utils/token.ts
Normal file
20
src/renderer/src/utils/token.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user