├── client ├── src │ ├── react-app-env.d.ts │ ├── types.ts │ ├── components │ │ ├── layout │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ ├── header │ │ │ ├── index.module.css │ │ │ └── index.tsx │ │ ├── error-message │ │ │ └── index.tsx │ │ ├── custom-input │ │ │ └── index.tsx │ │ ├── custom-button │ │ │ └── index.tsx │ │ ├── employee-form │ │ │ └── index.tsx │ │ └── password-input │ │ │ └── index.tsx │ ├── paths.ts │ ├── features │ │ ├── counter │ │ │ ├── counterAPI.ts │ │ │ ├── counterSlice.spec.ts │ │ │ ├── Counter.module.css │ │ │ ├── Counter.tsx │ │ │ └── counterSlice.ts │ │ ├── auth │ │ │ ├── auth.tsx │ │ │ └── authSlice.ts │ │ └── employees │ │ │ └── employeesSlice.ts │ ├── setupTests.ts │ ├── utils │ │ └── is-error-with-message.ts │ ├── app │ │ ├── hooks.ts │ │ ├── serivices │ │ │ ├── api.ts │ │ │ ├── auth.ts │ │ │ └── employees.ts │ │ └── store.ts │ ├── index.css │ ├── reportWebVitals.ts │ ├── middleware │ │ └── auth.ts │ ├── pages │ │ ├── status │ │ │ └── index.tsx │ │ ├── add-employee │ │ │ └── index.tsx │ │ ├── edit-employee │ │ │ └── index.tsx │ │ ├── employees │ │ │ └── index.tsx │ │ ├── login │ │ │ └── index.tsx │ │ ├── register │ │ │ └── index.tsx │ │ └── employee │ │ │ └── index.tsx │ └── index.tsx ├── public │ ├── robots.txt │ ├── favicon.ico │ ├── logo192.png │ ├── logo512.png │ ├── manifest.json │ └── index.html ├── .gitignore ├── tsconfig.json ├── package.json └── README.md ├── .gitignore ├── prisma ├── migrations │ ├── migration_lock.toml │ └── 20230115102237_init │ │ └── migration.sql ├── prisma-client.js └── schema.prisma ├── routes ├── users.js └── employees.js ├── .env.local ├── app.js ├── middleware └── auth.js ├── package.json ├── README.md └── controllers ├── employees.js └── users.js /client/src/react-app-env.d.ts: -------------------------------------------------------------------------------- 1 | /// 2 | -------------------------------------------------------------------------------- /client/public/robots.txt: -------------------------------------------------------------------------------- 1 | # https://www.robotstxt.org/robotstxt.html 2 | User-agent: * 3 | Disallow: 4 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | # Keep environment variables out of version control 3 | .env 4 | 5 | prisma/dev.db -------------------------------------------------------------------------------- /client/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian7346/employees-react-express/HEAD/client/public/favicon.ico -------------------------------------------------------------------------------- /client/public/logo192.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian7346/employees-react-express/HEAD/client/public/logo192.png -------------------------------------------------------------------------------- /client/public/logo512.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/brian7346/employees-react-express/HEAD/client/public/logo512.png -------------------------------------------------------------------------------- /client/src/types.ts: -------------------------------------------------------------------------------- 1 | export type ErrorWithMessage = { 2 | status: number; 3 | data: { 4 | message: string 5 | } 6 | } -------------------------------------------------------------------------------- /prisma/migrations/migration_lock.toml: -------------------------------------------------------------------------------- 1 | # Please do not edit this file manually 2 | # It should be added in your version-control system (i.e. Git) 3 | provider = "sqlite" -------------------------------------------------------------------------------- /prisma/prisma-client.js: -------------------------------------------------------------------------------- 1 | const PrismaClient = require('@prisma/client').PrismaClient; 2 | 3 | const prisma = new PrismaClient(); 4 | 5 | module.exports = { 6 | prisma 7 | } -------------------------------------------------------------------------------- /client/src/components/layout/index.module.css: -------------------------------------------------------------------------------- 1 | .main { 2 | width: 80%; 3 | height: 100%; 4 | margin-inline: auto; 5 | /* display: flex; 6 | flex-direction: column; */ 7 | } -------------------------------------------------------------------------------- /client/src/paths.ts: -------------------------------------------------------------------------------- 1 | export const Paths = { 2 | home: '/', 3 | employeeAdd: '/employee/add', 4 | employeeEdit: '/employee/edit', 5 | employee: '/employee', 6 | status: '/status', 7 | login: '/login', 8 | register: '/register' 9 | } as const; -------------------------------------------------------------------------------- /client/src/features/counter/counterAPI.ts: -------------------------------------------------------------------------------- 1 | // A mock function to mimic making an async request for data 2 | export function fetchCount(amount = 1) { 3 | return new Promise<{ data: number }>((resolve) => 4 | setTimeout(() => resolve({ data: amount }), 500) 5 | ); 6 | } 7 | -------------------------------------------------------------------------------- /client/src/components/header/index.module.css: -------------------------------------------------------------------------------- 1 | .header { 2 | padding: 20px 0; 3 | display: flex; 4 | align-items: center; 5 | margin-bottom: 20px; 6 | justify-content: space-between; 7 | } 8 | 9 | .teamIcon { 10 | font-size: 26px; 11 | margin-right: 12px; 12 | } -------------------------------------------------------------------------------- /client/src/setupTests.ts: -------------------------------------------------------------------------------- 1 | // jest-dom adds custom jest matchers for asserting on DOM nodes. 2 | // allows you to do things like: 3 | // expect(element).toHaveTextContent(/react/i) 4 | // learn more: https://github.com/testing-library/jest-dom 5 | import '@testing-library/jest-dom/extend-expect'; 6 | -------------------------------------------------------------------------------- /client/src/features/auth/auth.tsx: -------------------------------------------------------------------------------- 1 | import { useCurrentQuery } from "../../app/serivices/auth"; 2 | 3 | export const Auth = ({ children }: { children: JSX.Element }) => { 4 | const { isLoading } = useCurrentQuery(); 5 | 6 | if(isLoading) { 7 | return Загрузка 8 | } 9 | 10 | return children 11 | } 12 | -------------------------------------------------------------------------------- /client/src/components/error-message/index.tsx: -------------------------------------------------------------------------------- 1 | import { Alert } from "antd"; 2 | import React from "react"; 3 | 4 | type Props = { 5 | message?: string; 6 | }; 7 | 8 | export const ErrorMessage = ({ message }: Props) => { 9 | if (!message) { 10 | return null; 11 | } 12 | 13 | return ; 14 | }; 15 | -------------------------------------------------------------------------------- /client/src/utils/is-error-with-message.ts: -------------------------------------------------------------------------------- 1 | import { ErrorWithMessage } from "../types" 2 | 3 | export const isErrorWithMessage = (error: unknown): error is ErrorWithMessage => { 4 | return ( 5 | typeof error === 'object' && 6 | error !== null && 7 | 'data' in error && 8 | typeof (error as Record).data === 'object' 9 | ) 10 | } -------------------------------------------------------------------------------- /routes/users.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { login, register, current } = require("../controllers/users"); 4 | const { auth } = require('../middleware/auth'); 5 | 6 | router.post("/login", login); 7 | router.post("/register", register); 8 | router.get("/current", auth, current); 9 | 10 | module.exports = router; -------------------------------------------------------------------------------- /client/src/app/hooks.ts: -------------------------------------------------------------------------------- 1 | import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux'; 2 | import type { RootState, AppDispatch } from './store'; 3 | 4 | // Use throughout your app instead of plain `useDispatch` and `useSelector` 5 | export const useAppDispatch = () => useDispatch(); 6 | export const useAppSelector: TypedUseSelectorHook = useSelector; 7 | -------------------------------------------------------------------------------- /client/src/index.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Poppins:wght@500&display=swap'); 2 | 3 | * { 4 | margin: 0; 5 | padding: 0; 6 | box-sizing: border-box; 7 | } 8 | 9 | html, body, body > div { 10 | height: 100%; 11 | } 12 | 13 | body { 14 | font-family: 'Poppins', sans-serif; 15 | background-color: #141414; 16 | color: white; 17 | } 18 | 19 | a { 20 | text-decoration: none; 21 | } -------------------------------------------------------------------------------- /client/.gitignore: -------------------------------------------------------------------------------- 1 | # See https://help.github.com/articles/ignoring-files/ for more about ignoring files. 2 | 3 | # dependencies 4 | /node_modules 5 | /.pnp 6 | .pnp.js 7 | 8 | # testing 9 | /coverage 10 | 11 | # production 12 | /build 13 | 14 | # misc 15 | .DS_Store 16 | .env.local 17 | .env.development.local 18 | .env.test.local 19 | .env.production.local 20 | 21 | npm-debug.log* 22 | yarn-debug.log* 23 | yarn-error.log* 24 | -------------------------------------------------------------------------------- /routes/employees.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const router = express.Router(); 3 | const { add, edit, remove, all, employee } = require("../controllers/employees"); 4 | const { auth } = require('../middleware/auth'); 5 | 6 | router.get("/", auth, all); 7 | router.get("/:id", auth, employee); 8 | router.post("/add", auth, add); 9 | router.post("/remove/:id", auth, remove); 10 | router.put("/edit/:id", auth, edit); 11 | 12 | module.exports = router; 13 | -------------------------------------------------------------------------------- /client/src/components/layout/index.tsx: -------------------------------------------------------------------------------- 1 | import { Layout as AntLayout} from "antd"; 2 | import styles from "./index.module.css"; 3 | import { Header } from "../header"; 4 | 5 | type Props = { 6 | children: React.ReactNode; 7 | } 8 | 9 | export const Layout = ({ children }: Props) => { 10 | return ( 11 |
12 |
13 | 14 | {children} 15 | 16 |
17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /client/src/reportWebVitals.ts: -------------------------------------------------------------------------------- 1 | import { ReportHandler } from 'web-vitals'; 2 | 3 | const reportWebVitals = (onPerfEntry?: ReportHandler) => { 4 | if (onPerfEntry && onPerfEntry instanceof Function) { 5 | import('web-vitals').then(({ getCLS, getFID, getFCP, getLCP, getTTFB }) => { 6 | getCLS(onPerfEntry); 7 | getFID(onPerfEntry); 8 | getFCP(onPerfEntry); 9 | getLCP(onPerfEntry); 10 | getTTFB(onPerfEntry); 11 | }); 12 | } 13 | }; 14 | 15 | export default reportWebVitals; 16 | -------------------------------------------------------------------------------- /client/src/middleware/auth.ts: -------------------------------------------------------------------------------- 1 | import { createListenerMiddleware } from '@reduxjs/toolkit' 2 | import { authApi } from '../app/serivices/auth' 3 | 4 | export const listenerMiddleware = createListenerMiddleware() 5 | 6 | listenerMiddleware.startListening({ 7 | matcher: authApi.endpoints.login.matchFulfilled, 8 | effect: async (action, listenerApi) => { 9 | listenerApi.cancelActiveListeners() 10 | 11 | if (action.payload.token) { 12 | localStorage.setItem('token', action.payload.token); 13 | } 14 | }, 15 | }) -------------------------------------------------------------------------------- /.env.local: -------------------------------------------------------------------------------- 1 | PORT = 8000 2 | JWT_SECRET = "pWG#zXQj@6_ymI'swe1AL|}Gq!iD3K" 3 | 4 | # This was inserted by `prisma init`: 5 | # Environment variables declared in this file are automatically made available to Prisma. 6 | # See the documentation for more detail: https://pris.ly/d/prisma-schema#accessing-environment-variables-from-the-schema 7 | 8 | # Prisma supports the native connection string format for PostgreSQL, MySQL, SQLite, SQL Server, MongoDB and CockroachDB. 9 | # See the documentation for all the connection string options: https://pris.ly/d/connection-strings 10 | 11 | DATABASE_URL="file:./dev.db" -------------------------------------------------------------------------------- /client/public/manifest.json: -------------------------------------------------------------------------------- 1 | { 2 | "short_name": "React App", 3 | "name": "Create React App Sample", 4 | "icons": [ 5 | { 6 | "src": "favicon.ico", 7 | "sizes": "64x64 32x32 24x24 16x16", 8 | "type": "image/x-icon" 9 | }, 10 | { 11 | "src": "logo192.png", 12 | "type": "image/png", 13 | "sizes": "192x192" 14 | }, 15 | { 16 | "src": "logo512.png", 17 | "type": "image/png", 18 | "sizes": "512x512" 19 | } 20 | ], 21 | "start_url": ".", 22 | "display": "standalone", 23 | "theme_color": "#000000", 24 | "background_color": "#ffffff" 25 | } 26 | -------------------------------------------------------------------------------- /prisma/migrations/20230115102237_init/migration.sql: -------------------------------------------------------------------------------- 1 | -- CreateTable 2 | CREATE TABLE "User" ( 3 | "id" TEXT NOT NULL PRIMARY KEY, 4 | "email" TEXT NOT NULL, 5 | "password" TEXT NOT NULL, 6 | "name" TEXT NOT NULL 7 | ); 8 | 9 | -- CreateTable 10 | CREATE TABLE "Employee" ( 11 | "id" TEXT NOT NULL PRIMARY KEY, 12 | "firstName" TEXT NOT NULL, 13 | "lastName" TEXT NOT NULL, 14 | "age" TEXT NOT NULL, 15 | "address" TEXT NOT NULL, 16 | "userId" TEXT NOT NULL, 17 | CONSTRAINT "Employee_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE RESTRICT ON UPDATE CASCADE 18 | ); 19 | -------------------------------------------------------------------------------- /app.js: -------------------------------------------------------------------------------- 1 | const express = require('express'); 2 | const cookieParser = require('cookie-parser'); 3 | const logger = require('morgan'); 4 | const cors = require('cors'); 5 | require('dotenv').config(); 6 | 7 | const PORT = process.env.PORT 8 | 9 | const app = express(); 10 | 11 | app.use(cors()) 12 | app.use(logger('dev')); 13 | app.use(express.json()); 14 | app.use(express.urlencoded({ extended: false })); 15 | app.use(cookieParser()); 16 | 17 | app.use('/api/user', require("./routes/users")); 18 | app.use('/api/employees', require("./routes/employees")); 19 | 20 | app.listen(PORT, () => { 21 | console.log(`App listening on port ${PORT}`); 22 | }); -------------------------------------------------------------------------------- /middleware/auth.js: -------------------------------------------------------------------------------- 1 | const jwt = require("jsonwebtoken"); 2 | const { prisma } = require("../prisma/prisma-client"); 3 | 4 | const auth = async (req, res, next) => { 5 | try { 6 | let token = req.headers.authorization?.split(" ")[1]; 7 | 8 | const decoded = jwt.verify(token, process.env.JWT_SECRET); 9 | 10 | const user = await prisma.user.findUnique({ 11 | where: { 12 | id: decoded.id, 13 | }, 14 | }); 15 | 16 | req.user = user; 17 | 18 | next(); 19 | } catch (error) { 20 | res.status(401).json({ message: 'Не авторизован' }); 21 | } 22 | }; 23 | 24 | module.exports = { 25 | auth, 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/components/custom-input/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Form, Input } from "antd"; 3 | 4 | type Props = { 5 | name: string; 6 | placeholder: string; 7 | type?: string; 8 | }; 9 | 10 | export const CustomInput = ({ 11 | type = 'text', 12 | name, 13 | placeholder, 14 | }: Props) => { 15 | return ( 16 | 21 | 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /client/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "es5", 4 | "lib": [ 5 | "dom", 6 | "dom.iterable", 7 | "esnext" 8 | ], 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "esModuleInterop": true, 12 | "allowSyntheticDefaultImports": true, 13 | "strict": true, 14 | "forceConsistentCasingInFileNames": true, 15 | "noFallthroughCasesInSwitch": true, 16 | "module": "esnext", 17 | "moduleResolution": "node", 18 | "resolveJsonModule": true, 19 | "isolatedModules": true, 20 | "noEmit": true, 21 | "jsx": "react-jsx" 22 | }, 23 | "include": [ 24 | "src" 25 | ] 26 | } 27 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "new-app", 3 | "version": "0.0.0", 4 | "private": true, 5 | "scripts": { 6 | "start": "node app.js", 7 | "server": "nodemon app.js", 8 | "client": "npm start --prefix client", 9 | "dev": "concurrently \"npm run server\" \"npm run client\"" 10 | }, 11 | "dependencies": { 12 | "@prisma/client": "^4.8.1", 13 | "bcrypt": "^5.1.0", 14 | "concurrently": "^7.6.0", 15 | "cookie-parser": "~1.4.4", 16 | "cors": "^2.8.5", 17 | "debug": "~2.6.9", 18 | "dotenv": "^16.0.3", 19 | "express": "~4.16.1", 20 | "jsonwebtoken": "^9.0.0", 21 | "morgan": "~1.9.1" 22 | }, 23 | "devDependencies": { 24 | "nodemon": "^2.0.20", 25 | "prisma": "^4.8.1" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /prisma/schema.prisma: -------------------------------------------------------------------------------- 1 | // This is your Prisma schema file, 2 | // learn more about it in the docs: https://pris.ly/d/prisma-schema 3 | 4 | generator client { 5 | provider = "prisma-client-js" 6 | } 7 | 8 | datasource db { 9 | provider = "sqlite" 10 | url = env("DATABASE_URL") 11 | } 12 | 13 | model User { 14 | id String @id @default(uuid()) 15 | email String 16 | password String 17 | name String 18 | createdEmployee Employee[] 19 | } 20 | 21 | model Employee { 22 | id String @id @default(uuid()) 23 | firstName String 24 | lastName String 25 | age String 26 | address String 27 | user User @relation(fields: [userId], references: [id]) 28 | userId String 29 | } 30 | -------------------------------------------------------------------------------- /client/src/app/serivices/api.ts: -------------------------------------------------------------------------------- 1 | import { createApi, fetchBaseQuery, retry } from "@reduxjs/toolkit/query/react"; 2 | import { RootState } from "../store"; 3 | 4 | const baseQuery = fetchBaseQuery({ 5 | baseUrl: "http://localhost:8000/api", 6 | prepareHeaders: (headers, { getState }) => { 7 | const token = 8 | (getState() as RootState).auth.user?.token || 9 | localStorage.getItem("token"); 10 | 11 | if (token) { 12 | headers.set("authorization", `Bearer ${token}`); 13 | } 14 | return headers; 15 | }, 16 | }); 17 | 18 | const baseQueryWithRetry = retry(baseQuery, { maxRetries: 1 }); 19 | 20 | export const api = createApi({ 21 | reducerPath: "splitApi", 22 | baseQuery: baseQueryWithRetry, 23 | refetchOnMountOrArgChange: true, 24 | endpoints: () => ({}), 25 | }); 26 | -------------------------------------------------------------------------------- /client/src/pages/status/index.tsx: -------------------------------------------------------------------------------- 1 | import { Button, Result, Row } from "antd"; 2 | import { Link, useParams } from "react-router-dom"; 3 | 4 | const Statuses: Record = { 5 | created: "Пользователь успешно создан", 6 | updated: "Пользователь успешно обновлён", 7 | deleted: "Пользователь успешно удалён", 8 | }; 9 | 10 | export const Status = () => { 11 | const { status } = useParams(); 12 | 13 | return ( 14 | 15 | 20 | На главную 21 | 22 | } 23 | /> 24 | 25 | ); 26 | }; 27 | -------------------------------------------------------------------------------- /client/src/features/employees/employeesSlice.ts: -------------------------------------------------------------------------------- 1 | import { Employee } from "@prisma/client"; 2 | import { createSlice } from "@reduxjs/toolkit"; 3 | import { employeesApi } from "../../app/serivices/employees"; 4 | import { RootState } from "../../app/store"; 5 | 6 | interface InitialState { 7 | employees: Employee[] | null; 8 | } 9 | 10 | const initialState: InitialState = { 11 | employees: null, 12 | }; 13 | 14 | const slice = createSlice({ 15 | name: "employees", 16 | initialState, 17 | reducers: { 18 | logout: () => initialState, 19 | }, 20 | extraReducers: (builder) => { 21 | builder 22 | .addMatcher(employeesApi.endpoints.getAllEmployees.matchFulfilled, (state, action) => { 23 | state.employees = action.payload; 24 | }) 25 | }, 26 | }); 27 | 28 | export default slice.reducer; 29 | 30 | export const selectEmployees = (state: RootState) => 31 | state.employees.employees; 32 | -------------------------------------------------------------------------------- /client/src/app/store.ts: -------------------------------------------------------------------------------- 1 | import { configureStore, ThunkAction, Action } from "@reduxjs/toolkit"; 2 | import counterReducer from "../features/counter/counterSlice"; 3 | import { api } from "./serivices/api"; 4 | import auth from '../features/auth/authSlice' 5 | import employees from '../features/employees/employeesSlice' 6 | import { listenerMiddleware } from "../middleware/auth"; 7 | 8 | export const store = configureStore({ 9 | reducer: { 10 | counter: counterReducer, 11 | [api.reducerPath]: api.reducer, 12 | auth, 13 | employees 14 | }, 15 | middleware: (getDefaultMiddleware) => 16 | getDefaultMiddleware().concat(api.middleware).prepend(listenerMiddleware.middleware), 17 | }); 18 | 19 | export type AppDispatch = typeof store.dispatch; 20 | export type RootState = ReturnType; 21 | export type AppThunk = ThunkAction< 22 | ReturnType, 23 | RootState, 24 | unknown, 25 | Action 26 | >; 27 | -------------------------------------------------------------------------------- /client/src/features/counter/counterSlice.spec.ts: -------------------------------------------------------------------------------- 1 | import counterReducer, { 2 | CounterState, 3 | increment, 4 | decrement, 5 | incrementByAmount, 6 | } from './counterSlice'; 7 | 8 | describe('counter reducer', () => { 9 | const initialState: CounterState = { 10 | value: 3, 11 | status: 'idle', 12 | }; 13 | it('should handle initial state', () => { 14 | expect(counterReducer(undefined, { type: 'unknown' })).toEqual({ 15 | value: 0, 16 | status: 'idle', 17 | }); 18 | }); 19 | 20 | it('should handle increment', () => { 21 | const actual = counterReducer(initialState, increment()); 22 | expect(actual.value).toEqual(4); 23 | }); 24 | 25 | it('should handle decrement', () => { 26 | const actual = counterReducer(initialState, decrement()); 27 | expect(actual.value).toEqual(2); 28 | }); 29 | 30 | it('should handle incrementByAmount', () => { 31 | const actual = counterReducer(initialState, incrementByAmount(2)); 32 | expect(actual.value).toEqual(5); 33 | }); 34 | }); 35 | -------------------------------------------------------------------------------- /client/src/components/custom-button/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { Form, Button } from "antd"; 3 | 4 | type Props = { 5 | children: React.ReactNode; 6 | htmlType?: "button" | "submit" | "reset" | undefined; 7 | onClick?: () => void; 8 | type?: "primary" | "link" | "text" | "ghost" | "default" | "dashed"; 9 | danger?: boolean; 10 | loading?: boolean; 11 | shape?: "circle" | "default" | "round" | undefined; 12 | icon?: React.ReactNode; 13 | }; 14 | 15 | export const CustomButton = ({ 16 | children, 17 | type, 18 | danger, 19 | loading, 20 | htmlType = 'button', 21 | onClick, 22 | shape, 23 | icon 24 | }: Props) => { 25 | return ( 26 | 27 | 39 | 40 | ); 41 | }; 42 | -------------------------------------------------------------------------------- /client/src/app/serivices/auth.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import { api } from "./api"; 3 | 4 | export type UserData = Omit; 5 | type ResponseLoginData = User & { token: string }; 6 | 7 | export const authApi = api.injectEndpoints({ 8 | endpoints: (builder) => ({ 9 | login: builder.mutation({ 10 | query: (userData) => ({ 11 | url: "/user/login", 12 | method: "POST", 13 | body: userData, 14 | }), 15 | }), 16 | register: builder.mutation({ 17 | query: (userData) => ({ 18 | url: "/user/register", 19 | method: "POST", 20 | body: userData, 21 | }), 22 | }), 23 | current: builder.query({ 24 | query: () => ({ 25 | url: "/user/current", 26 | method: "GET", 27 | }), 28 | }), 29 | }), 30 | }); 31 | 32 | export const { useRegisterMutation, useLoginMutation, useCurrentQuery } = 33 | authApi; 34 | 35 | export const { 36 | endpoints: { login, register, current }, 37 | } = authApi; 38 | -------------------------------------------------------------------------------- /client/src/components/employee-form/index.tsx: -------------------------------------------------------------------------------- 1 | import { Employee } from "@prisma/client"; 2 | import { Form, Card, Space } from "antd"; 3 | import { CustomButton } from "../custom-button"; 4 | import { CustomInput } from "../custom-input"; 5 | import { ErrorMessage } from "../error-message"; 6 | 7 | type Props = { 8 | onFinish: (values: T) => void; 9 | btnText: string; 10 | title: string; 11 | error?: string; 12 | employee?: T; 13 | }; 14 | 15 | export const EmployeeForm = ({ 16 | onFinish, 17 | title, 18 | employee, 19 | btnText, 20 | error, 21 | }: Props) => { 22 | return ( 23 | 24 |
25 | 26 | 27 | 28 | 29 | 30 | 31 | {btnText} 32 | 33 | 34 |
35 | ); 36 | }; 37 | -------------------------------------------------------------------------------- /client/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "client", 3 | "version": "0.1.0", 4 | "private": true, 5 | "dependencies": { 6 | "@reduxjs/toolkit": "^1.9.1", 7 | "@testing-library/jest-dom": "^5.16.5", 8 | "@testing-library/react": "^13.4.0", 9 | "@testing-library/user-event": "^14.4.3", 10 | "@types/jest": "^27.5.2", 11 | "@types/node": "^17.0.45", 12 | "@types/react": "^18.0.26", 13 | "@types/react-dom": "^18.0.10", 14 | "antd": "^5.1.5", 15 | "react": "^18.2.0", 16 | "react-dom": "^18.2.0", 17 | "react-redux": "^8.0.5", 18 | "react-router-dom": "^6.6.2", 19 | "react-scripts": "5.0.1", 20 | "typescript": "^4.9.4", 21 | "web-vitals": "^2.1.4" 22 | }, 23 | "scripts": { 24 | "start": "react-scripts start", 25 | "build": "react-scripts build", 26 | "test": "react-scripts test", 27 | "eject": "react-scripts eject" 28 | }, 29 | "eslintConfig": { 30 | "extends": [ 31 | "react-app", 32 | "react-app/jest" 33 | ] 34 | }, 35 | "browserslist": { 36 | "production": [ 37 | ">0.2%", 38 | "not dead", 39 | "not op_mini all" 40 | ], 41 | "development": [ 42 | "last 1 chrome version", 43 | "last 1 firefox version", 44 | "last 1 safari version" 45 | ] 46 | } 47 | } 48 | -------------------------------------------------------------------------------- /client/src/features/auth/authSlice.ts: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import { createSlice } from "@reduxjs/toolkit"; 3 | import { authApi } from "../../app/serivices/auth"; 4 | import { RootState } from "../../app/store"; 5 | 6 | interface InitialState { 7 | user: User & { token: string} | null; 8 | isAuthenticated: boolean; 9 | } 10 | 11 | const initialState: InitialState = { 12 | user: null, 13 | isAuthenticated: false, 14 | }; 15 | 16 | const slice = createSlice({ 17 | name: "auth", 18 | initialState, 19 | reducers: { 20 | logout: () => initialState, 21 | }, 22 | extraReducers: (builder) => { 23 | builder 24 | .addMatcher(authApi.endpoints.login.matchFulfilled, (state, action) => { 25 | state.user = action.payload; 26 | state.isAuthenticated = true; 27 | }) 28 | .addMatcher(authApi.endpoints.register.matchFulfilled, (state, action) => { 29 | state.user = action.payload; 30 | state.isAuthenticated = true; 31 | }) 32 | .addMatcher(authApi.endpoints.current.matchFulfilled, (state, action) => { 33 | state.user = action.payload; 34 | state.isAuthenticated = true; 35 | }); 36 | }, 37 | }); 38 | 39 | export const { logout } = slice.actions; 40 | export default slice.reducer; 41 | 42 | export const selectIsAuthenticated = (state: RootState) => 43 | state.auth.isAuthenticated; 44 | 45 | export const selectUser = (state: RootState) => 46 | state.auth.user; 47 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Для запуска проекта, необходимо выполнить следующие шаги: 2 | 3 | 1. Склонировать репозиторий проекта по ссылке https://github.com/brian7346/employees-react-express на свой компьютер. 4 | ``` 5 | git clone https://github.com/brian7346/employees-react-express.git 6 | ``` 7 | 8 | 2. Открыть терминал (или командную строку) и перейти в корневую директорию проекта. 9 | ``` 10 | cd employees-react-express 11 | ``` 12 | 13 | 3. Установить зависимости для серверной части проекта. Введите следующую команду в терминале: 14 | ``` 15 | npm install 16 | ``` 17 | 18 | 4. Переименовать файл .env.local (убрать .local) 19 | ``` 20 | .env 21 | ``` 22 | 23 | 5. Сгенерировать типы 24 | ``` 25 | npx prisma generate 26 | ``` 27 | 28 | 6. Создать базу данных и сделать миграцию 29 | ``` 30 | npx prisma migrate dev 31 | ``` 32 | 33 | 7. Перейти в директорию client и установить зависимости для клиентской части проекта. 34 | ``` 35 | cd client 36 | npm install 37 | ``` 38 | 39 | 8. Вернуться в корневую директорию проекта. 40 | ``` 41 | cd .. 42 | ``` 43 | 44 | 9. Запустить проект. Введите следующую команду в терминале: 45 | ``` 46 | npm run dev 47 | ``` 48 | 49 | 10. Открыть браузер и перейти по адресу http://localhost:3000, чтобы увидеть запущенный проект. 50 | 51 | Успешный запуск проекта должен показать список сотрудников в браузере. Если возникли какие-либо проблемы во время установки или запуска проекта, проверьте, что все вышеперечисленные шаги были выполнены правильно и в соответствии с инструкцией. 52 | -------------------------------------------------------------------------------- /client/src/components/password-input/index.tsx: -------------------------------------------------------------------------------- 1 | import { Form, Input } from "antd"; 2 | import { NamePath } from "antd/es/form/interface"; 3 | 4 | type Props = { 5 | name: string; 6 | placeholder: string; 7 | dependencies?: NamePath[]; 8 | }; 9 | 10 | export const PasswordInput = ({ 11 | name, 12 | placeholder, 13 | dependencies, 14 | }: Props) => { 15 | return ( 16 | ({ 26 | validator(_, value) { 27 | if (!value ) { 28 | return Promise.resolve(); 29 | } 30 | 31 | if (name === 'confirmPassword') { 32 | if (!value || getFieldValue("password") === value) { 33 | return Promise.resolve(); 34 | } 35 | return Promise.reject( 36 | new Error("Пароли должны совпадать") 37 | ); 38 | } else { 39 | if (value.length < 6) { 40 | return Promise.reject( 41 | new Error("Пароль должен быть длиньше 6-ти символов") 42 | ); 43 | } 44 | 45 | return Promise.resolve(); 46 | } 47 | }, 48 | }), 49 | ]} 50 | > 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/app/serivices/employees.ts: -------------------------------------------------------------------------------- 1 | import { Employee } from "@prisma/client"; 2 | import { api } from "./api"; 3 | 4 | export const employeesApi = api.injectEndpoints({ 5 | endpoints: (builder) => ({ 6 | getAllEmployees: builder.query({ 7 | query: () => ({ 8 | url: "/employees", 9 | method: "GET", 10 | }), 11 | }), 12 | getEmployee: builder.query({ 13 | query: (id) => ({ 14 | url: `/employees/${id}`, 15 | method: "GET", 16 | }), 17 | }), 18 | editEmployee: builder.mutation({ 19 | query: (employee) => ({ 20 | url: `/employees/edit/${employee.id}`, 21 | method: "PUT", 22 | body: employee, 23 | }), 24 | }), 25 | removeEmployee: builder.mutation({ 26 | query: (id) => ({ 27 | url: `/employees/remove/${id}`, 28 | method: "POST", 29 | body: { id }, 30 | }), 31 | }), 32 | addEmployee: builder.mutation({ 33 | query: (employee) => ({ 34 | url: "/employees/add", 35 | method: "POST", 36 | body: employee, 37 | }), 38 | }), 39 | }), 40 | }); 41 | 42 | export const { 43 | useGetAllEmployeesQuery, 44 | useGetEmployeeQuery, 45 | useEditEmployeeMutation, 46 | useRemoveEmployeeMutation, 47 | useAddEmployeeMutation, 48 | } = employeesApi; 49 | 50 | export const { 51 | endpoints: { 52 | getAllEmployees, 53 | getEmployee, 54 | editEmployee, 55 | removeEmployee, 56 | addEmployee, 57 | }, 58 | } = employeesApi; 59 | -------------------------------------------------------------------------------- /client/src/features/counter/Counter.module.css: -------------------------------------------------------------------------------- 1 | .row { 2 | display: flex; 3 | align-items: center; 4 | justify-content: center; 5 | } 6 | 7 | .row > button { 8 | margin-left: 4px; 9 | margin-right: 8px; 10 | } 11 | 12 | .row:not(:last-child) { 13 | margin-bottom: 16px; 14 | } 15 | 16 | .value { 17 | font-size: 78px; 18 | padding-left: 16px; 19 | padding-right: 16px; 20 | margin-top: 2px; 21 | font-family: 'Courier New', Courier, monospace; 22 | } 23 | 24 | .button { 25 | appearance: none; 26 | background: none; 27 | font-size: 32px; 28 | padding-left: 12px; 29 | padding-right: 12px; 30 | outline: none; 31 | border: 2px solid transparent; 32 | color: rgb(112, 76, 182); 33 | padding-bottom: 4px; 34 | cursor: pointer; 35 | background-color: rgba(112, 76, 182, 0.1); 36 | border-radius: 2px; 37 | transition: all 0.15s; 38 | } 39 | 40 | .textbox { 41 | font-size: 32px; 42 | padding: 2px; 43 | width: 64px; 44 | text-align: center; 45 | margin-right: 4px; 46 | } 47 | 48 | .button:hover, 49 | .button:focus { 50 | border: 2px solid rgba(112, 76, 182, 0.4); 51 | } 52 | 53 | .button:active { 54 | background-color: rgba(112, 76, 182, 0.2); 55 | } 56 | 57 | .asyncButton { 58 | composes: button; 59 | position: relative; 60 | } 61 | 62 | .asyncButton:after { 63 | content: ''; 64 | background-color: rgba(112, 76, 182, 0.15); 65 | display: block; 66 | position: absolute; 67 | width: 100%; 68 | height: 100%; 69 | left: 0; 70 | top: 0; 71 | opacity: 0; 72 | transition: width 1s linear, opacity 0.5s ease 1s; 73 | } 74 | 75 | .asyncButton:active:after { 76 | width: 0%; 77 | opacity: 1; 78 | transition: 0s; 79 | } 80 | -------------------------------------------------------------------------------- /client/src/pages/add-employee/index.tsx: -------------------------------------------------------------------------------- 1 | import { Row } from "antd"; 2 | import { useState } from "react"; 3 | import { EmployeeForm } from "../../components/employee-form"; 4 | import { useNavigate } from "react-router-dom"; 5 | import { Layout } from "../../components/layout"; 6 | import { useSelector } from "react-redux"; 7 | import { selectUser } from "../../features/auth/authSlice"; 8 | import { useEffect } from "react"; 9 | import { useAddEmployeeMutation } from "../../app/serivices/employees"; 10 | import { Employee } from "@prisma/client"; 11 | import { isErrorWithMessage } from "../../utils/is-error-with-message"; 12 | import { Paths } from "../../paths"; 13 | 14 | export const AddEmployee = () => { 15 | const navigate = useNavigate(); 16 | const user = useSelector(selectUser); 17 | const [error, setError] = useState(""); 18 | const [addEmployee] = useAddEmployeeMutation(); 19 | 20 | useEffect(() => { 21 | if (!user) { 22 | navigate("/login"); 23 | } 24 | }, [user, navigate]); 25 | 26 | const handleAddEmployee = async (data: Employee) => { 27 | try { 28 | await addEmployee(data).unwrap(); 29 | 30 | navigate(`${Paths.status}/created`); 31 | } catch (err) { 32 | const maybeError = isErrorWithMessage(err); 33 | 34 | if (maybeError) { 35 | setError(err.data.message); 36 | } else { 37 | setError("Неизвестная ошибка"); 38 | } 39 | } 40 | }; 41 | 42 | return ( 43 | 44 | 45 | 51 | 52 | 53 | ); 54 | }; 55 | -------------------------------------------------------------------------------- /client/src/pages/edit-employee/index.tsx: -------------------------------------------------------------------------------- 1 | import { Employee } from "@prisma/client"; 2 | import { Row } from "antd"; 3 | import { useState } from 'react'; 4 | import { useNavigate, useParams } from "react-router-dom"; 5 | import { useEditEmployeeMutation, useGetEmployeeQuery } from "../../app/serivices/employees"; 6 | import { EmployeeForm } from "../../components/employee-form"; 7 | import { Layout } from "../../components/layout"; 8 | import { Paths } from "../../paths"; 9 | import { isErrorWithMessage } from "../../utils/is-error-with-message"; 10 | 11 | export const EditEmployee = () => { 12 | const navigate = useNavigate(); 13 | const params = useParams<{ id: string }>(); 14 | const [error, setError] = useState(""); 15 | const { data, isLoading } = useGetEmployeeQuery(params.id || ""); 16 | const [editEmployee] = useEditEmployeeMutation(); 17 | 18 | if (isLoading) { 19 | return Загрузка 20 | } 21 | 22 | const handleEditUser = async (employee: Employee) => { 23 | try { 24 | const editedEmployee = { 25 | ...data, 26 | ...employee 27 | }; 28 | 29 | await editEmployee(editedEmployee).unwrap(); 30 | 31 | navigate(`${Paths.status}/created`); 32 | } catch (err) { 33 | const maybeError = isErrorWithMessage(err); 34 | 35 | if (maybeError) { 36 | setError(err.data.message); 37 | } else { 38 | setError("Неизвестная ошибка"); 39 | } 40 | } 41 | }; 42 | 43 | return ( 44 | 45 | 46 | 53 | 54 | 55 | ); 56 | }; 57 | -------------------------------------------------------------------------------- /client/src/components/header/index.tsx: -------------------------------------------------------------------------------- 1 | import { 2 | TeamOutlined, 3 | LoginOutlined, 4 | LogoutOutlined, 5 | UserOutlined, 6 | } from "@ant-design/icons"; 7 | import { Layout, Space, Typography } from "antd"; 8 | import { useDispatch, useSelector } from "react-redux"; 9 | import { Link, useNavigate } from "react-router-dom"; 10 | import { logout, selectUser } from "../../features/auth/authSlice"; 11 | import { CustomButton } from "../custom-button"; 12 | import style from "./index.module.css"; 13 | 14 | export const Header = () => { 15 | const user = useSelector(selectUser); 16 | const navigate = useNavigate(); 17 | const dispatch = useDispatch(); 18 | 19 | const onLogoutClick = () => { 20 | dispatch(logout()); 21 | localStorage.removeItem("token"); 22 | navigate("/login"); 23 | }; 24 | 25 | return ( 26 | 27 | 28 | 29 | 30 | 31 | Сотрудники 32 | 33 | 34 | 35 | {user ? ( 36 | } 39 | onClick={onLogoutClick} 40 | > 41 | Выйти 42 | 43 | ) : ( 44 | 45 | 46 | }> 47 | Зарегистрироваться 48 | 49 | 50 | 51 | }> 52 | Войти 53 | 54 | 55 | 56 | )} 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /client/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 12 | 13 | 17 | 18 | 27 | React Redux App 28 | 29 | 30 | 31 |
32 | 42 | 43 | 44 | -------------------------------------------------------------------------------- /client/src/pages/employees/index.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from "react"; 2 | import { Table } from "antd"; 3 | import type { ColumnsType } from "antd/es/table"; 4 | import { PlusCircleOutlined } from '@ant-design/icons'; 5 | import { CustomButton } from "../../components/custom-button"; 6 | import { Employee } from "@prisma/client"; 7 | import { Paths } from "../../paths"; 8 | import { useNavigate } from "react-router-dom"; 9 | import { useGetAllEmployeesQuery } from "../../app/serivices/employees"; 10 | import { Layout } from "../../components/layout"; 11 | import { selectUser } from "../../features/auth/authSlice"; 12 | import { useSelector } from "react-redux"; 13 | 14 | const columns: ColumnsType = [ 15 | { 16 | title: "Имя", 17 | dataIndex: "firstName", 18 | key: "firstName", 19 | }, 20 | { 21 | title: "Возраст", 22 | dataIndex: "age", 23 | key: "age", 24 | }, 25 | { 26 | title: "Адрес", 27 | dataIndex: "address", 28 | key: "address", 29 | }, 30 | ]; 31 | 32 | export const Employees = () => { 33 | const navigate = useNavigate(); 34 | const user = useSelector(selectUser); 35 | const { data, isLoading } = useGetAllEmployeesQuery(); 36 | 37 | useEffect(() => { 38 | if (!user) { 39 | navigate("/login"); 40 | } 41 | }, [user, navigate]); 42 | 43 | const gotToAddUser = () => navigate(Paths.employeeAdd); 44 | 45 | return ( 46 | 47 | }> 48 | Добавить 49 | 50 | record.id} 53 | columns={columns} 54 | dataSource={data} 55 | pagination={false} 56 | onRow={(record) => { 57 | return { 58 | onClick: () => navigate(`${Paths.employee}/${record.id}`), 59 | }; 60 | }} 61 | /> 62 | 63 | ); 64 | }; 65 | -------------------------------------------------------------------------------- /client/src/index.tsx: -------------------------------------------------------------------------------- 1 | import React from "react"; 2 | import { createRoot } from "react-dom/client"; 3 | import { Provider } from "react-redux"; 4 | import { store } from "./app/store"; 5 | import reportWebVitals from "./reportWebVitals"; 6 | import { ConfigProvider, theme } from "antd"; 7 | import { createBrowserRouter, RouterProvider } from "react-router-dom"; 8 | import { AddEmployee } from "./pages/add-employee"; 9 | import { Employees } from "./pages/employees"; 10 | import { Register } from "./pages/register"; 11 | import { Login } from "./pages/login"; 12 | import { Employee } from "./pages/employee"; 13 | import { Status } from "./pages/status"; 14 | import { EditEmployee } from "./pages/edit-employee"; 15 | import { Auth } from "./features/auth/auth"; 16 | import { Paths } from "./paths"; 17 | import "./index.css"; 18 | 19 | const router = createBrowserRouter([ 20 | { 21 | path: Paths.home, 22 | element: , 23 | }, 24 | { 25 | path: Paths.login, 26 | element: , 27 | }, 28 | { 29 | path: Paths.register, 30 | element: , 31 | }, 32 | { 33 | path: Paths.employeeAdd, 34 | element: , 35 | }, 36 | { 37 | path: `${Paths.employee}/:id`, 38 | element: , 39 | }, 40 | { 41 | path: `${Paths.employeeEdit}/:id`, 42 | element: , 43 | }, 44 | { 45 | path: `${Paths.status}/:status`, 46 | element: , 47 | }, 48 | ]); 49 | 50 | const container = document.getElementById("root")!; 51 | const root = createRoot(container); 52 | 53 | root.render( 54 | 55 | 56 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | ); 68 | 69 | reportWebVitals(); 70 | -------------------------------------------------------------------------------- /client/src/features/counter/Counter.tsx: -------------------------------------------------------------------------------- 1 | import React, { useState } from 'react'; 2 | 3 | import { useAppSelector, useAppDispatch } from '../../app/hooks'; 4 | import { 5 | decrement, 6 | increment, 7 | incrementByAmount, 8 | incrementAsync, 9 | incrementIfOdd, 10 | selectCount, 11 | } from './counterSlice'; 12 | import styles from './Counter.module.css'; 13 | 14 | export function Counter() { 15 | const count = useAppSelector(selectCount); 16 | const dispatch = useAppDispatch(); 17 | const [incrementAmount, setIncrementAmount] = useState('2'); 18 | 19 | const incrementValue = Number(incrementAmount) || 0; 20 | 21 | return ( 22 |
23 |
24 | 31 | {count} 32 | 39 |
40 |
41 | setIncrementAmount(e.target.value)} 46 | /> 47 | 53 | 59 | 65 |
66 |
67 | ); 68 | } 69 | -------------------------------------------------------------------------------- /client/README.md: -------------------------------------------------------------------------------- 1 | # Getting Started with Create React App 2 | 3 | This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), using the [Redux](https://redux.js.org/) and [Redux Toolkit](https://redux-toolkit.js.org/) TS template. 4 | 5 | ## Available Scripts 6 | 7 | In the project directory, you can run: 8 | 9 | ### `npm start` 10 | 11 | Runs the app in the development mode.\ 12 | Open [http://localhost:3000](http://localhost:3000) to view it in the browser. 13 | 14 | The page will reload if you make edits.\ 15 | You will also see any lint errors in the console. 16 | 17 | ### `npm test` 18 | 19 | Launches the test runner in the interactive watch mode.\ 20 | See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information. 21 | 22 | ### `npm run build` 23 | 24 | Builds the app for production to the `build` folder.\ 25 | It correctly bundles React in production mode and optimizes the build for the best performance. 26 | 27 | The build is minified and the filenames include the hashes.\ 28 | Your app is ready to be deployed! 29 | 30 | See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information. 31 | 32 | ### `npm run eject` 33 | 34 | **Note: this is a one-way operation. Once you `eject`, you can’t go back!** 35 | 36 | If you aren’t satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project. 37 | 38 | Instead, it will copy all the configuration files and the transitive dependencies (webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point you’re on your own. 39 | 40 | You don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t customize it when you are ready for it. 41 | 42 | ## Learn More 43 | 44 | You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started). 45 | 46 | To learn React, check out the [React documentation](https://reactjs.org/). 47 | -------------------------------------------------------------------------------- /client/src/pages/login/index.tsx: -------------------------------------------------------------------------------- 1 | import { Card, Form, Row, Space, Typography } from "antd"; 2 | import { useState, useEffect } from "react"; 3 | import { useSelector } from "react-redux"; 4 | import { Link, useNavigate } from "react-router-dom"; 5 | import { useLoginMutation, UserData } from "../../app/serivices/auth"; 6 | import { CustomButton } from "../../components/custom-button"; 7 | import { CustomInput } from "../../components/custom-input"; 8 | import { ErrorMessage } from "../../components/error-message"; 9 | import { Layout } from "../../components/layout"; 10 | import { PasswordInput } from "../../components/password-input"; 11 | import { selectUser } from "../../features/auth/authSlice"; 12 | import { Paths } from "../../paths"; 13 | import { isErrorWithMessage } from "../../utils/is-error-with-message"; 14 | 15 | export const Login = () => { 16 | const navigate = useNavigate(); 17 | const [error, setError] = useState(""); 18 | const user = useSelector(selectUser); 19 | const [loginUser, loginUserResult] = useLoginMutation(); 20 | 21 | useEffect(() => { 22 | if (user) { 23 | navigate("/"); 24 | } 25 | }, [user, navigate]); 26 | 27 | const login = async (data: UserData) => { 28 | try { 29 | await loginUser(data).unwrap(); 30 | 31 | navigate("/"); 32 | } catch (err) { 33 | const maybeError = isErrorWithMessage(err); 34 | 35 | if (maybeError) { 36 | setError(err.data.message); 37 | } else { 38 | setError("Неизвестная ошибка"); 39 | } 40 | } 41 | }; 42 | 43 | return ( 44 | 45 | 46 | 47 |
48 | 49 | 50 | 55 | Войти 56 | 57 | 58 | 59 | 60 | Нет аккаунта? Зарегистрируйтесь 61 | 62 | 63 | 64 |
65 |
66 |
67 | ); 68 | }; 69 | -------------------------------------------------------------------------------- /client/src/pages/register/index.tsx: -------------------------------------------------------------------------------- 1 | import { User } from "@prisma/client"; 2 | import { Card, Form, Row, Space, Typography } from "antd"; 3 | import { useEffect, useState } from "react"; 4 | import { useSelector } from "react-redux"; 5 | import { Link, useNavigate } from "react-router-dom"; 6 | import { useRegisterMutation } from "../../app/serivices/auth"; 7 | import { CustomButton } from "../../components/custom-button"; 8 | import { CustomInput } from "../../components/custom-input"; 9 | import { ErrorMessage } from "../../components/error-message"; 10 | import { Layout } from "../../components/layout"; 11 | import { PasswordInput } from "../../components/password-input"; 12 | import { selectUser } from "../../features/auth/authSlice"; 13 | import { Paths } from "../../paths"; 14 | import { isErrorWithMessage } from "../../utils/is-error-with-message"; 15 | 16 | type RegisterData = Omit & { confirmPassword: string }; 17 | 18 | export const Register = () => { 19 | const navigate = useNavigate(); 20 | const user = useSelector(selectUser); 21 | const [error, setError] = useState(""); 22 | const [registerUser] = useRegisterMutation(); 23 | 24 | useEffect(() => { 25 | if (user) { 26 | navigate("/"); 27 | } 28 | }, [user, navigate]); 29 | 30 | const register = async (data: RegisterData) => { 31 | try { 32 | await registerUser(data).unwrap(); 33 | 34 | navigate("/"); 35 | } catch (err) { 36 | const maybeError = isErrorWithMessage(err); 37 | 38 | if (maybeError) { 39 | setError(err.data.message); 40 | } else { 41 | setError("Неизвестная ошибка"); 42 | } 43 | } 44 | }; 45 | 46 | return ( 47 | 48 | 49 | 50 |
51 | 52 | 53 | 54 | 55 | 56 | Зарегистрироваться 57 | 58 | 59 | 60 | 61 | Уже зарегистрированы? Войдите 62 | 63 | 64 | 65 |
66 |
67 |
68 | ); 69 | }; 70 | -------------------------------------------------------------------------------- /controllers/employees.js: -------------------------------------------------------------------------------- 1 | const { prisma } = require("../prisma/prisma-client"); 2 | 3 | /** 4 | * @route GET /api/employees 5 | * @desc Получение всех сотрудников 6 | * @access Private 7 | */ 8 | const all = async (req, res) => { 9 | try { 10 | const employees = await prisma.employee.findMany(); 11 | 12 | res.status(200).json(employees); 13 | } catch { 14 | res.status(500).json({ message: "Не удалось получить сотрудников" }); 15 | } 16 | }; 17 | 18 | /** 19 | * @route POST /api/employees/add 20 | * @desc Добавление сотрудника 21 | * @access Private 22 | */ 23 | const add = async (req, res) => { 24 | try { 25 | const data = req.body; 26 | 27 | if (!data.firstName || !data.lastName || !data.address || !data.age) { 28 | return res.status(400).json({ message: "Все поля обязательные" }); 29 | } 30 | 31 | const employee = await prisma.employee.create({ 32 | data: { 33 | ...data, 34 | userId: req.user.id, 35 | }, 36 | }); 37 | 38 | return res.status(201).json(employee); 39 | } catch (err) { 40 | console.log(err); 41 | res.status(500).json({ message: "Что-то пошло не так" }); 42 | } 43 | }; 44 | 45 | /** 46 | * @route POST /api/empoyees/remove/:id 47 | * @desc Удаление сотрудника 48 | * @access Private 49 | */ 50 | const remove = async (req, res) => { 51 | const { id } = req.body; 52 | 53 | try { 54 | await prisma.employee.delete({ 55 | where: { 56 | id, 57 | }, 58 | }); 59 | 60 | res.status(204).json("OK"); 61 | } catch { 62 | res.status(500).json({ message: "Не удалось удалить сотрудника" }); 63 | } 64 | }; 65 | 66 | /** 67 | * @route PUT /api/empoyees/edit/:id 68 | * @desc Редактирование сотрудника 69 | * @access Private 70 | */ 71 | const edit = async (req, res) => { 72 | const data = req.body; 73 | const id = data.id; 74 | 75 | try { 76 | await prisma.employee.update({ 77 | where: { 78 | id, 79 | }, 80 | data, 81 | }); 82 | 83 | res.status(204).json("OK"); 84 | } catch(err) { 85 | res.status(500).json({ message: "Не удалось редактировать сотрудника" }); 86 | } 87 | }; 88 | 89 | /** 90 | * @route GET /api/employees/:id 91 | * @desc Получение сотрудника 92 | * @access Private 93 | */ 94 | const employee = async (req, res) => { 95 | const { id } = req.params; // http://localhost:8000/api/employees/9fe371c1-361f-494a-9def-465959ecc098 96 | 97 | try { 98 | const employee = await prisma.employee.findUnique({ 99 | where: { 100 | id, 101 | }, 102 | }); 103 | 104 | res.status(200).json(employee); 105 | } catch { 106 | res.status(500).json({ message: "Не удалось получить сотрудника" }); 107 | } 108 | }; 109 | 110 | module.exports = { 111 | all, 112 | add, 113 | remove, 114 | edit, 115 | employee, 116 | }; 117 | -------------------------------------------------------------------------------- /controllers/users.js: -------------------------------------------------------------------------------- 1 | const { prisma } = require('../prisma/prisma-client'); 2 | const brypt = require('bcrypt'); 3 | const jwt = require('jsonwebtoken'); 4 | 5 | /** 6 | * @route POST /api/user/login 7 | * @desс Логин 8 | * @access Public 9 | */ 10 | const login = async (req, res) => { 11 | try { 12 | const { email, password } = req.body; 13 | 14 | if (!email || !password) { 15 | return res.status(400).json({ message: 'Пожалуйста, заполните обязятельные поля' }) 16 | } 17 | 18 | const user = await prisma.user.findFirst({ 19 | where: { 20 | email, 21 | } 22 | }); 23 | 24 | const isPasswordCorrect = user && (await brypt.compare(password, user.password)); 25 | const secret = process.env.JWT_SECRET; 26 | 27 | if (user && isPasswordCorrect && secret) { 28 | res.status(200).json({ 29 | id: user.id, 30 | email: user.email, 31 | name: user.name, 32 | token: jwt.sign({ id: user.id }, secret, { expiresIn: '30d' }) 33 | }) 34 | } else { 35 | return res.status(400).json({ message: 'Неверно введен логин или пароль' }) 36 | } 37 | } catch { 38 | res.status(500).json({ message: 'Что-то пошло не так' }) 39 | } 40 | } 41 | 42 | /** 43 | * 44 | * @route POST /api/user/register 45 | * @desc Регистрация 46 | * @access Public 47 | */ 48 | const register = async (req, res, next) => { 49 | try { 50 | const { email, password, name } = req.body; 51 | 52 | if(!email || !password || !name) { 53 | return res.status(400).json({ message: 'Пожалуйста, заполните обязательные поля' }) 54 | } 55 | 56 | const registeredUser = await prisma.user.findFirst({ 57 | where: { 58 | email 59 | } 60 | }); 61 | 62 | if (registeredUser) { 63 | return res.status(400).json({ message: 'Пользователь, с таким email уже существует' }) 64 | } 65 | 66 | const salt = await brypt.genSalt(10); 67 | const hashedPassord = await brypt.hash(password, salt); 68 | 69 | const user = await prisma.user.create({ 70 | data: { 71 | email, 72 | name, 73 | password: hashedPassord 74 | } 75 | }); 76 | 77 | const secret = process.env.JWT_SECRET; 78 | 79 | if (user && secret) { 80 | res.status(201).json({ 81 | id: user.id, 82 | email: user.email, 83 | name, 84 | token: jwt.sign({ id: user.id }, secret, { expiresIn: '30d' }) 85 | }) 86 | } else { 87 | return res.status(400).json({ message: 'Не удалось создать пользователя' }) 88 | } 89 | } catch { 90 | res.status(500).json({ message: 'Что-то пошло не так' }) 91 | } 92 | } 93 | 94 | /** 95 | * 96 | * @route GET /api/user/current 97 | * @desc Текущий пользователь 98 | * @access Private 99 | */ 100 | const current = async (req, res) => { 101 | return res.status(200).json(req.user) 102 | } 103 | 104 | module.exports = { 105 | login, 106 | register, 107 | current 108 | } -------------------------------------------------------------------------------- /client/src/features/counter/counterSlice.ts: -------------------------------------------------------------------------------- 1 | import { createAsyncThunk, createSlice, PayloadAction } from '@reduxjs/toolkit'; 2 | import { RootState, AppThunk } from '../../app/store'; 3 | import { fetchCount } from './counterAPI'; 4 | 5 | export interface CounterState { 6 | value: number; 7 | status: 'idle' | 'loading' | 'failed'; 8 | } 9 | 10 | const initialState: CounterState = { 11 | value: 0, 12 | status: 'idle', 13 | }; 14 | 15 | // The function below is called a thunk and allows us to perform async logic. It 16 | // can be dispatched like a regular action: `dispatch(incrementAsync(10))`. This 17 | // will call the thunk with the `dispatch` function as the first argument. Async 18 | // code can then be executed and other actions can be dispatched. Thunks are 19 | // typically used to make async requests. 20 | export const incrementAsync = createAsyncThunk( 21 | 'counter/fetchCount', 22 | async (amount: number) => { 23 | const response = await fetchCount(amount); 24 | // The value we return becomes the `fulfilled` action payload 25 | return response.data; 26 | } 27 | ); 28 | 29 | export const counterSlice = createSlice({ 30 | name: 'counter', 31 | initialState, 32 | // The `reducers` field lets us define reducers and generate associated actions 33 | reducers: { 34 | increment: (state) => { 35 | // Redux Toolkit allows us to write "mutating" logic in reducers. It 36 | // doesn't actually mutate the state because it uses the Immer library, 37 | // which detects changes to a "draft state" and produces a brand new 38 | // immutable state based off those changes 39 | state.value += 1; 40 | }, 41 | decrement: (state) => { 42 | state.value -= 1; 43 | }, 44 | // Use the PayloadAction type to declare the contents of `action.payload` 45 | incrementByAmount: (state, action: PayloadAction) => { 46 | state.value += action.payload; 47 | }, 48 | }, 49 | // The `extraReducers` field lets the slice handle actions defined elsewhere, 50 | // including actions generated by createAsyncThunk or in other slices. 51 | extraReducers: (builder) => { 52 | builder 53 | .addCase(incrementAsync.pending, (state) => { 54 | state.status = 'loading'; 55 | }) 56 | .addCase(incrementAsync.fulfilled, (state, action) => { 57 | state.status = 'idle'; 58 | state.value += action.payload; 59 | }) 60 | .addCase(incrementAsync.rejected, (state) => { 61 | state.status = 'failed'; 62 | }); 63 | }, 64 | }); 65 | 66 | export const { increment, decrement, incrementByAmount } = counterSlice.actions; 67 | 68 | // The function below is called a selector and allows us to select a value from 69 | // the state. Selectors can also be defined inline where they're used instead of 70 | // in the slice file. For example: `useSelector((state: RootState) => state.counter.value)` 71 | export const selectCount = (state: RootState) => state.counter.value; 72 | 73 | // We can also write thunks by hand, which may contain both sync and async logic. 74 | // Here's an example of conditionally dispatching actions based on current state. 75 | export const incrementIfOdd = 76 | (amount: number): AppThunk => 77 | (dispatch, getState) => { 78 | const currentValue = selectCount(getState()); 79 | if (currentValue % 2 === 1) { 80 | dispatch(incrementByAmount(amount)); 81 | } 82 | }; 83 | 84 | export default counterSlice.reducer; 85 | -------------------------------------------------------------------------------- /client/src/pages/employee/index.tsx: -------------------------------------------------------------------------------- 1 | import { EditOutlined, DeleteOutlined } from "@ant-design/icons"; 2 | import { Descriptions, Space, Divider, Modal } from "antd"; 3 | import { CustomButton } from "../../components/custom-button"; 4 | import { useState } from "react"; 5 | import { Paths } from "../../paths"; 6 | import { useNavigate, Link, useParams, Navigate } from "react-router-dom"; 7 | import { 8 | useGetEmployeeQuery, 9 | useRemoveEmployeeMutation, 10 | } from "../../app/serivices/employees"; 11 | import { Layout } from "../../components/layout"; 12 | import { isErrorWithMessage } from "../../utils/is-error-with-message"; 13 | import { ErrorMessage } from "../../components/error-message"; 14 | import { useSelector } from "react-redux"; 15 | import { selectUser } from "../../features/auth/authSlice"; 16 | 17 | export const Employee = () => { 18 | const navigate = useNavigate(); 19 | const [error, setError] = useState(""); 20 | const params = useParams<{ id: string }>(); 21 | const [isModalOpen, setIsModalOpen] = useState(false); 22 | const { data, isLoading } = useGetEmployeeQuery(params.id || ""); 23 | const [removeEmployee] = useRemoveEmployeeMutation(); 24 | const user = useSelector(selectUser); 25 | 26 | if (isLoading) { 27 | return Загрузка; 28 | } 29 | 30 | if (!data) { 31 | return ; 32 | } 33 | 34 | const showModal = () => { 35 | setIsModalOpen(true); 36 | }; 37 | 38 | const hideModal = () => { 39 | setIsModalOpen(false); 40 | }; 41 | 42 | const handleDeleteUser = async () => { 43 | hideModal(); 44 | 45 | try { 46 | await removeEmployee(data.id).unwrap(); 47 | 48 | navigate(`${Paths.status}/deleted`); 49 | } catch (err) { 50 | const maybeError = isErrorWithMessage(err); 51 | 52 | if (maybeError) { 53 | setError(err.data.message); 54 | } else { 55 | setError("Неизвестная ошибка"); 56 | } 57 | } 58 | }; 59 | 60 | return ( 61 | 62 | 63 | {`${data.firstName} ${data.lastName}`} 67 | 68 | {data.age} 69 | 70 | 71 | {data.address} 72 | 73 | 74 | {user?.id === data.userId && ( 75 | <> 76 | Действия 77 | 78 | 79 | } 83 | > 84 | Редактировать 85 | 86 | 87 | } 92 | > 93 | Удалить 94 | 95 | 96 | 97 | )} 98 | 99 | 107 | Вы действительно хотите удалить сотрудника из таблицы? 108 | 109 | 110 | ); 111 | }; 112 | --------------------------------------------------------------------------------