From bcf26db8b06516482d6b42f0d12ae5b188ed6ab8 Mon Sep 17 00:00:00 2001 From: DavidMakarov Date: Mon, 9 Jun 2025 13:40:09 +0400 Subject: [PATCH] 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 --- package-lock.json | 47 ++++ package.json | 3 + src/renderer/src/Layout.tsx | 46 +++- src/renderer/src/components/AppSidebar.tsx | 111 +++++++++ src/renderer/src/components/FormErrors.tsx | 18 ++ src/renderer/src/components/LoginPage.tsx | 9 - src/renderer/src/components/MainPage.tsx | 7 - src/renderer/src/components/NavUser.tsx | 71 ++++++ src/renderer/src/hooks/userStore.ts | 18 ++ src/renderer/src/lib/utils.ts | 6 + src/renderer/src/main.tsx | 58 ++++- src/renderer/src/models/company/dtos.ts | 48 ++++ src/renderer/src/models/company/types.ts | 6 + src/renderer/src/models/user/dtos.ts | 40 ++++ src/renderer/src/models/user/types.ts | 6 + .../src/{components => pages}/ErrorPage.tsx | 2 +- src/renderer/src/pages/MainPage.tsx | 23 ++ .../pages/company/login/LoginCompanyPage.tsx | 85 +++++++ src/renderer/src/pages/login/LoginPage.tsx | 70 ++++++ .../src/pages/profile/EmployeeReducer.ts | 65 +++++ .../src/pages/profile/ProfilePage.tsx | 224 ++++++++++++++++++ .../src/pages/register/RegisterPage.tsx | 175 ++++++++++++++ .../src/pages/register/RegisterReducer.ts | 76 ++++++ src/renderer/src/services/address.ts | 0 src/renderer/src/services/auth.ts | 61 +++++ src/renderer/src/services/company.ts | 43 ++++ src/renderer/src/services/user.ts | 65 +++++ src/renderer/src/services/utils.ts | 1 + src/renderer/src/utils/requestWrapper.ts | 19 ++ src/renderer/src/utils/token.ts | 20 ++ 30 files changed, 1395 insertions(+), 28 deletions(-) create mode 100644 src/renderer/src/components/AppSidebar.tsx create mode 100644 src/renderer/src/components/FormErrors.tsx delete mode 100644 src/renderer/src/components/LoginPage.tsx delete mode 100644 src/renderer/src/components/MainPage.tsx create mode 100644 src/renderer/src/components/NavUser.tsx create mode 100644 src/renderer/src/hooks/userStore.ts create mode 100644 src/renderer/src/lib/utils.ts create mode 100644 src/renderer/src/models/company/dtos.ts create mode 100644 src/renderer/src/models/company/types.ts create mode 100644 src/renderer/src/models/user/dtos.ts create mode 100644 src/renderer/src/models/user/types.ts rename src/renderer/src/{components => pages}/ErrorPage.tsx (51%) create mode 100644 src/renderer/src/pages/MainPage.tsx create mode 100644 src/renderer/src/pages/company/login/LoginCompanyPage.tsx create mode 100644 src/renderer/src/pages/login/LoginPage.tsx create mode 100644 src/renderer/src/pages/profile/EmployeeReducer.ts create mode 100644 src/renderer/src/pages/profile/ProfilePage.tsx create mode 100644 src/renderer/src/pages/register/RegisterPage.tsx create mode 100644 src/renderer/src/pages/register/RegisterReducer.ts create mode 100644 src/renderer/src/services/address.ts create mode 100644 src/renderer/src/services/auth.ts create mode 100644 src/renderer/src/services/company.ts create mode 100644 src/renderer/src/services/user.ts create mode 100644 src/renderer/src/services/utils.ts create mode 100644 src/renderer/src/utils/requestWrapper.ts create mode 100644 src/renderer/src/utils/token.ts diff --git a/package-lock.json b/package-lock.json index 20f66e2..145bea5 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 4019b08..f5c9a14 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/src/renderer/src/Layout.tsx b/src/renderer/src/Layout.tsx index 5802929..a1d42d1 100644 --- a/src/renderer/src/Layout.tsx +++ b/src/renderer/src/Layout.tsx @@ -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 ( - <> - - + + + {token && userData && } + + + + ); }; diff --git a/src/renderer/src/components/AppSidebar.tsx b/src/renderer/src/components/AppSidebar.tsx new file mode 100644 index 0000000..431ffba --- /dev/null +++ b/src/renderer/src/components/AppSidebar.tsx @@ -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) => { + const loc = useLocation(); + const userData = useUserStore((s) => s.userData); + if (userData === null) + return null; + return ( + + + + + + +
+ Мой Управдом +
+ +
+
+
+
+ + + + + + Главное + + + + + + + + + Адреса + + + + + + + Чаты + + + + + + + Уведомления + + + + + + + + + + + + Работа с пользователями + + + + + + + + + Заявки на присоединения + + + + + + + + + + + + +
+ ) +}; + +export default AppSidebar; diff --git a/src/renderer/src/components/FormErrors.tsx b/src/renderer/src/components/FormErrors.tsx new file mode 100644 index 0000000..026828a --- /dev/null +++ b/src/renderer/src/components/FormErrors.tsx @@ -0,0 +1,18 @@ +interface FormErrorProps { + errors: string[], + className?: string +} + +const FormError = ({ errors, className }: FormErrorProps) => { + return ( +
+ { + errors.map((error) => ( +

{error}

+ )) + } +
+ ) +}; + +export default FormError; diff --git a/src/renderer/src/components/LoginPage.tsx b/src/renderer/src/components/LoginPage.tsx deleted file mode 100644 index 71538f6..0000000 --- a/src/renderer/src/components/LoginPage.tsx +++ /dev/null @@ -1,9 +0,0 @@ -const LoginPage = () => { - return ( -
- LoginPage -
- ); -}; - -export default LoginPage; \ No newline at end of file diff --git a/src/renderer/src/components/MainPage.tsx b/src/renderer/src/components/MainPage.tsx deleted file mode 100644 index e36a640..0000000 --- a/src/renderer/src/components/MainPage.tsx +++ /dev/null @@ -1,7 +0,0 @@ -const MainPage = () => { - return ( -
Hello, World!
- ) -}; - -export default MainPage; \ No newline at end of file diff --git a/src/renderer/src/components/NavUser.tsx b/src/renderer/src/components/NavUser.tsx new file mode 100644 index 0000000..3ba711d --- /dev/null +++ b/src/renderer/src/components/NavUser.tsx @@ -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 ( + + + + + +
+ {user.login} + Работник +
+ +
+
+ + +
+
+ {user.login} +
+
+
+ + + + Профиль + + + Уведомления + + + + + Выйти из аккаунта + +
+
+
+
+ ) +}; + +export default NavUser; \ No newline at end of file diff --git a/src/renderer/src/hooks/userStore.ts b/src/renderer/src/hooks/userStore.ts new file mode 100644 index 0000000..82b45ec --- /dev/null +++ b/src/renderer/src/hooks/userStore.ts @@ -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((set) => ({ + token: null, + userData: null, + setToken: (token) => set({ token }), + setUserData: (userData) => set({ userData }), + logout: () => set({ token: null, userData: null }), +})); \ No newline at end of file diff --git a/src/renderer/src/lib/utils.ts b/src/renderer/src/lib/utils.ts new file mode 100644 index 0000000..21c2d46 --- /dev/null +++ b/src/renderer/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from "clsx" +import { twMerge } from "tailwind-merge" + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} \ No newline at end of file diff --git a/src/renderer/src/main.tsx b/src/renderer/src/main.tsx index eb46294..1cee317 100644 --- a/src/renderer/src/main.tsx +++ b/src/renderer/src/main.tsx @@ -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: , @@ -21,14 +27,50 @@ const router = createHashRouter( { path: '/login', element: , - } + }, + { + path: '/register', + element: , + }, + { + path: '/profile', + element: , + }, + { + path: '/requests', + }, + { + path: '/notifications', + }, + { + path: '/addresses', + }, + { + path: '/address/:id', + }, + { + path: '/chats', + }, + { + path: '/chat/:id', + }, + { + path: '/company/login', + element: , + }, + { + path: '/company/employees', + }, + { + path: '/company/users', + }, ] } ], { basename: '/' } -) +); createRoot(document.getElementById('root')!).render( diff --git a/src/renderer/src/models/company/dtos.ts b/src/renderer/src/models/company/dtos.ts new file mode 100644 index 0000000..dc9d507 --- /dev/null +++ b/src/renderer/src/models/company/dtos.ts @@ -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(), +}); \ No newline at end of file diff --git a/src/renderer/src/models/company/types.ts b/src/renderer/src/models/company/types.ts new file mode 100644 index 0000000..e477736 --- /dev/null +++ b/src/renderer/src/models/company/types.ts @@ -0,0 +1,6 @@ +import { z } from "zod/v4"; +import { CompanyDto, CompanyLoginDto, CompanyRegisterDto } from "@renderer/models/company/dtos"; + +export type CompanyRegisterDto = z.infer; +export type CompanyLoginDto = z.infer; +export type CompanyDto = z.infer; \ No newline at end of file diff --git a/src/renderer/src/models/user/dtos.ts b/src/renderer/src/models/user/dtos.ts new file mode 100644 index 0000000..b712c35 --- /dev/null +++ b/src/renderer/src/models/user/dtos.ts @@ -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(), +}); \ No newline at end of file diff --git a/src/renderer/src/models/user/types.ts b/src/renderer/src/models/user/types.ts new file mode 100644 index 0000000..c4506a7 --- /dev/null +++ b/src/renderer/src/models/user/types.ts @@ -0,0 +1,6 @@ +import z from "zod/v4"; +import { UserEmployeeDto, UserLoginDto, EmployeeUpdateDto } from "./dtos"; + +export type UserEmployeeDto = z.infer; +export type UserLoginDto = z.infer; +export type EmployeeUpdateDto = z.infer; \ No newline at end of file diff --git a/src/renderer/src/components/ErrorPage.tsx b/src/renderer/src/pages/ErrorPage.tsx similarity index 51% rename from src/renderer/src/components/ErrorPage.tsx rename to src/renderer/src/pages/ErrorPage.tsx index 5e267d4..8cc86a2 100644 --- a/src/renderer/src/components/ErrorPage.tsx +++ b/src/renderer/src/pages/ErrorPage.tsx @@ -1,7 +1,7 @@ const ErrorPage = () => { return (
- Произошла ошибка + Страница не найдена
) }; diff --git a/src/renderer/src/pages/MainPage.tsx b/src/renderer/src/pages/MainPage.tsx new file mode 100644 index 0000000..babe93a --- /dev/null +++ b/src/renderer/src/pages/MainPage.tsx @@ -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 ( +
+
Hello, World!
+
+ ) +}; + +export default MainPage; \ No newline at end of file diff --git a/src/renderer/src/pages/company/login/LoginCompanyPage.tsx b/src/renderer/src/pages/company/login/LoginCompanyPage.tsx new file mode 100644 index 0000000..c11b692 --- /dev/null +++ b/src/renderer/src/pages/company/login/LoginCompanyPage.tsx @@ -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(""); + const [loginError, setLoginError] = useState([]); + + useEffect(() => { + if (token) + navigate('/'); + }, []); + + const handleSubmit = async (e: React.FormEvent) => { + 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) => { + 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 ( +
+
+

Вход в аккаунт УК

+
+ + + +
+
+ + +
+
+ + + +
+
+
+ ); +}; + +export default LoginCompanyPage; \ No newline at end of file diff --git a/src/renderer/src/pages/login/LoginPage.tsx b/src/renderer/src/pages/login/LoginPage.tsx new file mode 100644 index 0000000..86dade7 --- /dev/null +++ b/src/renderer/src/pages/login/LoginPage.tsx @@ -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) => { + 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 ( +
+
+

Вход в аккаунт

+
+ + +
+
+ + +
+
+ + +
+
+
+ ); +}; + +export default LoginPage; \ No newline at end of file diff --git a/src/renderer/src/pages/profile/EmployeeReducer.ts b/src/renderer/src/pages/profile/EmployeeReducer.ts new file mode 100644 index 0000000..c448bf9 --- /dev/null +++ b/src/renderer/src/pages/profile/EmployeeReducer.ts @@ -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 }; \ No newline at end of file diff --git a/src/renderer/src/pages/profile/ProfilePage.tsx b/src/renderer/src/pages/profile/ProfilePage.tsx new file mode 100644 index 0000000..35ccee0 --- /dev/null +++ b/src/renderer/src/pages/profile/ProfilePage.tsx @@ -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(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) => { + 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 ( +
+ { !(companyData instanceof Error) && + + + + Ваша компания + + + + { + isLoading ? : + <> +
+

{companyData?.name}

+
+ +
+

{companyData?.phone}

+
+
+

{companyData?.workingDays}

+
+
+

{companyData?.workingHours}

+
+
+

{companyData?.locationCity}, {companyData?.locationRoad}, {companyData?.locationHouseNumber}

+
+
+ + } +
+
+ } + + + Профиль + + + Аккаунт создан {userData?.creationDate.toLocaleDateString()} +
+
+ + + +
+
+ + + +
+
+ + + +
+
+ + + +
+
+
+ + {edit ? + <> + + + + : + } + +
+
+ ) +}; + +export default ProfilePage; \ No newline at end of file diff --git a/src/renderer/src/pages/register/RegisterPage.tsx b/src/renderer/src/pages/register/RegisterPage.tsx new file mode 100644 index 0000000..e068a8c --- /dev/null +++ b/src/renderer/src/pages/register/RegisterPage.tsx @@ -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(''); + const [workingHoursTo, setWorkingHoursTo] = useState(''); + const [workingHoursError, setWorkingHoursError] = useState(''); + + const [workingDaysFrom, setWorkingDaysFrom] = useState(''); + const [workingDaysTo, setWorkingDaysTo] = useState(''); + const [workingDaysError, setWorkingDaysError] = useState(''); + + useEffect(() => { + if (token) + navigate('/'); + }, []); + + const handleInput = (field: keyof FormState["values"]) => (e: React.ChangeEvent) => { + 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) => { + setWorkingHoursFrom(e.target.value); + validateHours(e.target.value, workingHoursTo); + }; + + const handleHoursToChange = (e: React.ChangeEvent) => { + 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 ( +
+
+

Регистрация УК

+
+ + + +
+
+ + +
+
+ + + +
+
+ + + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+
+ ); +}; + +export default RegisterPage; \ No newline at end of file diff --git a/src/renderer/src/pages/register/RegisterReducer.ts b/src/renderer/src/pages/register/RegisterReducer.ts new file mode 100644 index 0000000..7202e1a --- /dev/null +++ b/src/renderer/src/pages/register/RegisterReducer.ts @@ -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 }; \ No newline at end of file diff --git a/src/renderer/src/services/address.ts b/src/renderer/src/services/address.ts new file mode 100644 index 0000000..e69de29 diff --git a/src/renderer/src/services/auth.ts b/src/renderer/src/services/auth.ts new file mode 100644 index 0000000..67109c5 --- /dev/null +++ b/src/renderer/src/services/auth.ts @@ -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 { + 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; +} \ No newline at end of file diff --git a/src/renderer/src/services/company.ts b/src/renderer/src/services/company.ts new file mode 100644 index 0000000..56a0626 --- /dev/null +++ b/src/renderer/src/services/company.ts @@ -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 { + 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 + }; +} \ No newline at end of file diff --git a/src/renderer/src/services/user.ts b/src/renderer/src/services/user.ts new file mode 100644 index 0000000..5c7f6f3 --- /dev/null +++ b/src/renderer/src/services/user.ts @@ -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{ + 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; +} \ No newline at end of file diff --git a/src/renderer/src/services/utils.ts b/src/renderer/src/services/utils.ts new file mode 100644 index 0000000..bb5d07c --- /dev/null +++ b/src/renderer/src/services/utils.ts @@ -0,0 +1 @@ +export const BASE_URL = import.meta.env.VITE_API_URL; \ No newline at end of file diff --git a/src/renderer/src/utils/requestWrapper.ts b/src/renderer/src/utils/requestWrapper.ts new file mode 100644 index 0000000..fe88413 --- /dev/null +++ b/src/renderer/src/utils/requestWrapper.ts @@ -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); + } +} diff --git a/src/renderer/src/utils/token.ts b/src/renderer/src/utils/token.ts new file mode 100644 index 0000000..2b80bc2 --- /dev/null +++ b/src/renderer/src/utils/token.ts @@ -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(token); + if (!decoded) return null; + if (decoded.is_company) + return COMPANY; + return EMPLOYEE; +}