diff --git a/package-lock.json b/package-lock.json index 7ed9811..5bd8ffc 100644 --- a/package-lock.json +++ b/package-lock.json @@ -17,6 +17,7 @@ "@fontsource/open-sans": "^5.1.0", "@lottiefiles/react-lottie-player": "^3.5.4", "@pbe/react-yandex-maps": "^1.2.5", + "@reduxjs/toolkit": "^2.5.0", "@types/react": "^18.3.12", "dayjs": "^1.11.13", "express": "^4.21.1", @@ -29,6 +30,7 @@ "react-i18next": "^15.1.1", "react-icons": "^5.3.0", "react-phone-number-input": "^3.4.9", + "react-redux": "^9.2.0", "react-router-dom": "^6.27.0" }, "devDependencies": { @@ -2639,6 +2641,38 @@ "url": "https://opencollective.com/popperjs" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.5.0.tgz", + "integrity": "sha512-awNe2oTodsZ6LmRqmkFhtb/KH03hUhxOamEQy411m3Njj3BbFvoBovxo4Q1cBWnV1ErprVj9MlF0UPXkng0eyg==", + "dependencies": { + "immer": "^10.0.3", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, + "node_modules/@reduxjs/toolkit/node_modules/immer": { + "version": "10.1.1", + "resolved": "https://registry.npmjs.org/immer/-/immer-10.1.1.tgz", + "integrity": "sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/@remix-run/router": { "version": "1.20.0", "resolved": "https://registry.npmjs.org/@remix-run/router/-/router-1.20.0.tgz", @@ -2802,6 +2836,11 @@ "@types/react": "*" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==" + }, "node_modules/@types/yandex-maps": { "version": "2.1.29", "resolved": "https://registry.npmjs.org/@types/yandex-maps/-/yandex-maps-2.1.29.tgz", @@ -8735,6 +8774,28 @@ "react-dom": ">=16.8" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, "node_modules/react-remove-scroll": { "version": "2.6.2", "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.6.2.tgz", @@ -8892,6 +8953,19 @@ "recursive-watch": "bin.js" } }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "peerDependencies": { + "redux": "^5.0.0" + } + }, "node_modules/reflect.getprototypeof": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", @@ -9009,6 +9083,11 @@ "node": ">=0.10.0" } }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==" + }, "node_modules/resolve": { "version": "1.22.8", "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.8.tgz", @@ -10249,6 +10328,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.4.0.tgz", + "integrity": "sha512-9WXSPC5fMv61vaupRkCKCxsPxBocVnwakBEkMIHHpkTTg6icbJtg6jzgtLDm4bl3cSHAca52rYWih0k4K3PfHw==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", diff --git a/package.json b/package.json index cde405f..c4e7e31 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "@fontsource/open-sans": "^5.1.0", "@lottiefiles/react-lottie-player": "^3.5.4", "@pbe/react-yandex-maps": "^1.2.5", + "@reduxjs/toolkit": "^2.5.0", "@types/react": "^18.3.12", "dayjs": "^1.11.13", "express": "^4.21.1", @@ -37,6 +38,7 @@ "react-i18next": "^15.1.1", "react-icons": "^5.3.0", "react-phone-number-input": "^3.4.9", + "react-redux": "^9.2.0", "react-router-dom": "^6.27.0" }, "devDependencies": { diff --git a/src/__data__/service/api.ts b/src/__data__/service/api.ts new file mode 100644 index 0000000..752f43d --- /dev/null +++ b/src/__data__/service/api.ts @@ -0,0 +1,41 @@ +import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react'; +import { getConfigValue } from '@brojs/cli'; + +import { Master } from '../../models/api/master'; + +type SuccessResponse = { + success: true; + body: Body; +}; + +type ErrorResponse = { + success: false; + message: string; +}; + +type BaseResponse = SuccessResponse | ErrorResponse; + +export const api = createApi({ + reducerPath: 'api', + baseQuery: fetchBaseQuery({ baseUrl: getConfigValue('dry-wash.api') }), + tagTypes: ['Masters'], + endpoints: (builder) => ({ + getMasters: builder.query({ + query: () => ({ url: '/arm/masters' }), + transformResponse: (response: BaseResponse) => { + if (response.success) { + return response.body; + } + }, + providesTags: ['Masters'], + }), + addMaster: builder.mutation>({ + query: (master) => ({ + url: '/arm/masters', + method: 'POST', + body: master, + }), + invalidatesTags: ['Masters'], + }), + }), +}); diff --git a/src/__data__/store.ts b/src/__data__/store.ts new file mode 100644 index 0000000..dabb855 --- /dev/null +++ b/src/__data__/store.ts @@ -0,0 +1,14 @@ +import { configureStore } from '@reduxjs/toolkit'; + +import { api } from './service/api'; + +export const store = configureStore({ + reducer: { + [api.reducerPath]: api.reducer, + }, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware().concat(api.middleware), +}); + +export type RootState = ReturnType; +export type AppDispatch = typeof store.dispatch; diff --git a/src/app.tsx b/src/app.tsx index 5bfd51d..31590e0 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,19 +1,23 @@ import React from 'react'; import { BrowserRouter } from 'react-router-dom'; import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react'; +import { Provider } from 'react-redux'; import Routers from './routes'; import ErrorBoundary from './components/ErrorBoundary'; +import { store } from './__data__/store'; const App = () => { return ( - - - - - - - + + + + + + + + + ); }; diff --git a/src/components/MasterDrawer/MasterDrawer.tsx b/src/components/MasterDrawer/MasterDrawer.tsx index 1f4ac79..fe790bb 100644 --- a/src/components/MasterDrawer/MasterDrawer.tsx +++ b/src/components/MasterDrawer/MasterDrawer.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useState } from 'react'; import { Button, FormControl, @@ -18,21 +18,29 @@ import { import { useTranslation } from 'react-i18next'; import { PhoneIcon } from '@chakra-ui/icons'; -import { armService } from '../../api/arm'; +import { api } from '../../__data__/service/api'; const MasterDrawer = ({ isOpen, onClose }) => { - const { addMaster } = armService(); + const [addMaster, { error, isSuccess }] = api.useAddMasterMutation(); const toast = useToast(); const [newMaster, setNewMaster] = useState({ name: '', phone: '' }); const handleSave = async () => { - if (newMaster.name.trim() === '' || newMaster.phone.trim() === '') { + const trimMaster = { + phone: newMaster.phone.trim(), + name: newMaster.name.trim(), + }; + + if (trimMaster.name === '' || trimMaster.phone === '') { return; } - try { - await addMaster(newMaster); + addMaster(trimMaster); + }; + + useEffect(() => { + if (isSuccess) { toast({ title: 'Мастер создан.', description: `Мастер "${newMaster.name}" успешно добавлен.`, @@ -42,7 +50,11 @@ const MasterDrawer = ({ isOpen, onClose }) => { position: 'top-right', }); onClose(); - } catch (error) { + } + }, [isSuccess]); + + useEffect(() => { + if (error) { toast({ title: 'Ошибка при создании мастера.', description: 'Не удалось добавить мастера. Попробуйте еще раз.', @@ -53,7 +65,7 @@ const MasterDrawer = ({ isOpen, onClose }) => { }); console.error(error); } - }; + }, [error]); const { t } = useTranslation('~', { keyPrefix: 'dry-wash.arm.master.drawer', diff --git a/src/components/MasterItem/MasterItem.tsx b/src/components/MasterItem/MasterItem.tsx index 6ea75f4..8df73de 100644 --- a/src/components/MasterItem/MasterItem.tsx +++ b/src/components/MasterItem/MasterItem.tsx @@ -7,19 +7,6 @@ import { getTimeSlot } from '../../lib'; import EditableWrapper from '../Editable/Editable'; import { armService } from '../../api/arm'; -export interface Schedule { - id: string; - startWashTime: string; - endWashTime: string; -} - -export type MasterProps = { - id: string; - name: string; - phone: string; - schedule: Schedule[]; -}; - const MasterItem = ({ name, phone, id, schedule }) => { const { updateMaster } = armService(); const { t } = useTranslation('~', { diff --git a/src/components/Masters/Masters.tsx b/src/components/Masters/Masters.tsx index 7846fc8..a7aff65 100644 --- a/src/components/Masters/Masters.tsx +++ b/src/components/Masters/Masters.tsx @@ -1,4 +1,4 @@ -import React, { useEffect, useState } from 'react'; +import React, { useEffect } from 'react'; import { Box, Heading, @@ -19,8 +19,7 @@ import { useTranslation } from 'react-i18next'; import MasterItem from '../MasterItem'; import MasterDrawer from '../MasterDrawer'; -import { armService } from '../../api/arm'; -import { MasterProps } from '../MasterItem/MasterItem'; +import { api } from '../../__data__/service/api'; const TABLE_HEADERS = [ 'name' as const, @@ -36,35 +35,23 @@ const Masters = () => { keyPrefix: 'dry-wash.arm.master', }); - const [masters, setMasters] = useState([]); - const [loading, setLoading] = useState(false); - const [error, setError] = useState(null); - - const { fetchMasters } = armService(); + const { + data: masters, + error, + isLoading, + isSuccess, + } = api.useGetMastersQuery(); useEffect(() => { - const loadMasters = async () => { - setLoading(true); - - try { - const data = await fetchMasters(); - setMasters(data.body); - } catch (err) { - setError(err.message); - toast({ - title: t('error.title'), - status: 'error', - duration: 5000, - isClosable: true, - position: 'bottom-right', - }); - } finally { - setLoading(false); - } - }; - - loadMasters(); - }, [toast, t]); + if (error) { + toast({ + title: t('error.title'), + status: 'error', + isClosable: true, + position: 'bottom-right', + }); + } + }, [error]); return ( @@ -83,22 +70,21 @@ const Masters = () => { - {loading && ( + {isLoading && ( )} - {!loading && masters.length === 0 && !error && ( + {isSuccess && masters.length === 0 && ( {t('table.empty')} )} - {!loading && - !error && + {isSuccess && masters.map((master, index) => ( ))} diff --git a/src/components/OrderItem/OrderItem.tsx b/src/components/OrderItem/OrderItem.tsx index 5e677a2..d622187 100644 --- a/src/components/OrderItem/OrderItem.tsx +++ b/src/components/OrderItem/OrderItem.tsx @@ -3,8 +3,8 @@ import { Td, Tr, Link, Select } from '@chakra-ui/react'; import { useTranslation } from 'react-i18next'; import dayjs from 'dayjs'; -import { MasterProps } from '../MasterItem/MasterItem'; import { getTimeSlot } from '../../lib'; +import { Master } from '../../models/api/master'; import { armService } from '../../api/arm'; const statuses = [ @@ -26,9 +26,9 @@ export type OrderProps = { status?: GetArrItemType; phone?: string; location?: string; - master: MasterProps; + master: Master; notes: ''; - allMasters: MasterProps[]; + allMasters: Master[]; id: string; }; diff --git a/src/components/Orders/Orders.tsx b/src/components/Orders/Orders.tsx index 99183bf..8a74f15 100644 --- a/src/components/Orders/Orders.tsx +++ b/src/components/Orders/Orders.tsx @@ -19,7 +19,7 @@ import OrderItem from '../OrderItem'; import { OrderProps } from '../OrderItem/OrderItem'; import { armService } from '../../api/arm'; import DateNavigator from '../DateNavigator'; -import { MasterProps } from '../MasterItem/MasterItem'; +import { Master } from '../../models/api/master'; const TABLE_HEADERS = [ 'carNumber' as const, @@ -41,7 +41,7 @@ const Orders = () => { const toast = useToast(); const [orders, setOrders] = useState([]); - const [allMasters, setAllMasters] = useState([]); + const [allMasters, setAllMasters] = useState([]); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); const [currentDate, setCurrentDate] = useState(new Date()); diff --git a/src/models/api/master.ts b/src/models/api/master.ts new file mode 100644 index 0000000..1fa2e9d --- /dev/null +++ b/src/models/api/master.ts @@ -0,0 +1,12 @@ +export interface Schedule { + id: string; + startWashTime: string; + endWashTime: string; +} + +export type Master = { + id: string; + name: string; + phone: string; + schedule: Schedule[]; +}; diff --git a/stubs/api/admin.js b/stubs/api/admin.js index 2f9e4eb..32cc16c 100644 --- a/stubs/api/admin.js +++ b/stubs/api/admin.js @@ -18,11 +18,13 @@ router.get('/', (req, res) => { Мастера ${generateRadioInput('masters', 'success')} ${generateRadioInput('masters', 'error')} + ${generateRadioInput('masters', 'empty')}
Заказы ${generateRadioInput('orders', 'success')} - ${generateRadioInput('orders', 'error')} + ${generateRadioInput('orders', 'error')} + ${generateRadioInput('orders', 'empty')}
Лендинг - Детали заказа @@ -40,4 +42,4 @@ function generateRadioInput(name, type) { ${type} `; -} \ No newline at end of file +} diff --git a/stubs/json/arm-masters/empty.json b/stubs/json/arm-masters/empty.json new file mode 100644 index 0000000..0acb92d --- /dev/null +++ b/stubs/json/arm-masters/empty.json @@ -0,0 +1,4 @@ +{ + "success": true, + "body": [] +} diff --git a/stubs/json/arm-orders/empty.json b/stubs/json/arm-orders/empty.json new file mode 100644 index 0000000..0acb92d --- /dev/null +++ b/stubs/json/arm-orders/empty.json @@ -0,0 +1,4 @@ +{ + "success": true, + "body": [] +}