Compare commits

..

No commits in common. "5e0ac9f7a5395dbe47a6184f197d26a19dc7428a" and "4cda998bd7ac564ac6cf0398fad088ece2c4cdda" have entirely different histories.

50 changed files with 1492 additions and 1279 deletions

View File

@ -39,10 +39,9 @@
"dry-wash.order-create.car-body-select.options.sports-car" : "Sports-car", "dry-wash.order-create.car-body-select.options.sports-car" : "Sports-car",
"dry-wash.order-create.car-body-select.options.other": "Other", "dry-wash.order-create.car-body-select.options.other": "Other",
"dry-wash.order-create.form.submit-button.label": "Submit", "dry-wash.order-create.form.submit-button.label": "Submit",
"dry-wash.order-create.create-order-query.success.title": "The order is successfully created",
"dry-wash.order-create.create-order-query.error.title": "Failed to create an order",
"dry-wash.order-view.title": "Your order", "dry-wash.order-view.title": "Your order",
"dry-wash.order-view.get-order-query.error.title": "Failed to fetch the details of order #{{number}}", "dry-wash.order-view.error.title": "Error",
"dry-wash.order-view.fetch.error": "Failed to fetch the details of order #{{number}}",
"dry-wash.order-view.details.title": "Order #{{number}}", "dry-wash.order-view.details.title": "Order #{{number}}",
"dry-wash.order-view.details.owner": "Owner", "dry-wash.order-view.details.owner": "Owner",
"dry-wash.order-view.details.car": "Car", "dry-wash.order-view.details.car": "Car",
@ -58,13 +57,11 @@
"dry-wash.arm.order.status.pending": "Pending", "dry-wash.arm.order.status.pending": "Pending",
"dry-wash.arm.order.status.working": "Working", "dry-wash.arm.order.status.working": "Working",
"dry-wash.arm.order.status.canceled": "Canceled", "dry-wash.arm.order.status.canceled": "Canceled",
"dry-wash.arm.order.status.placeholder": "Select status", "dry-wash.arm.order.status.placeholder": "Select Status",
"dry-wash.arm.order.master.placeholder": "Select master",
"dry-wash.arm.order.table.header.carNumber": "Car Number", "dry-wash.arm.order.table.header.carNumber": "Car Number",
"dry-wash.arm.order.table.header.washingTime": "Washing Time", "dry-wash.arm.order.table.header.washingTime": "Washing Time",
"dry-wash.arm.order.table.header.orderDate": "Order Date", "dry-wash.arm.order.table.header.orderDate": "Order Date",
"dry-wash.arm.order.table.header.status": "Status", "dry-wash.arm.order.table.header.status": "Status",
"dry-wash.arm.order.table.header.masters": "Master",
"dry-wash.arm.order.table.header.telephone": "Telephone", "dry-wash.arm.order.table.header.telephone": "Telephone",
"dry-wash.arm.order.table.header.location": "Location", "dry-wash.arm.order.table.header.location": "Location",
"dry-wash.arm.master.title": "Masters", "dry-wash.arm.master.title": "Masters",
@ -75,10 +72,6 @@
"dry-wash.arm.master.table.header.phone": "Phone", "dry-wash.arm.master.table.header.phone": "Phone",
"dry-wash.arm.master.table.header.actions": "Actions", "dry-wash.arm.master.table.header.actions": "Actions",
"dry-wash.arm.master.table.actionsMenu.delete": "Delete Master", "dry-wash.arm.master.table.actionsMenu.delete": "Delete Master",
"dry-wash.arm.master.schedule.empty": "free",
"dry-wash.arm.master.editable.aria.cancel": "Undo changes",
"dry-wash.arm.master.editable.aria.save": "Save changes ",
"dry-wash.arm.master.editable.aria.edit": "Edit",
"dry-wash.arm.master.drawer.title": "Add New Master", "dry-wash.arm.master.drawer.title": "Add New Master",
"dry-wash.arm.master.drawer.inputName.label": "Full Name", "dry-wash.arm.master.drawer.inputName.label": "Full Name",
"dry-wash.arm.master.drawer.inputName.placeholder": "Enter Full Name", "dry-wash.arm.master.drawer.inputName.placeholder": "Enter Full Name",

View File

@ -7,12 +7,10 @@
"dry-wash.arm.order.status.working": "В работе", "dry-wash.arm.order.status.working": "В работе",
"dry-wash.arm.order.status.canceled": "Отменено", "dry-wash.arm.order.status.canceled": "Отменено",
"dry-wash.arm.order.status.placeholder": "Выберите статус", "dry-wash.arm.order.status.placeholder": "Выберите статус",
"dry-wash.arm.order.master.placeholder": "Выберите мастера",
"dry-wash.arm.order.table.header.carNumber": "Номер машины", "dry-wash.arm.order.table.header.carNumber": "Номер машины",
"dry-wash.arm.order.table.header.washingTime": "Время мойки", "dry-wash.arm.order.table.header.washingTime": "Время мойки",
"dry-wash.arm.order.table.header.orderDate": "Дата заказа", "dry-wash.arm.order.table.header.orderDate": "Дата заказа",
"dry-wash.arm.order.table.header.status": "Статус", "dry-wash.arm.order.table.header.status": "Статус",
"dry-wash.arm.order.table.header.masters": "Мастер",
"dry-wash.arm.order.table.header.telephone": "Телефон", "dry-wash.arm.order.table.header.telephone": "Телефон",
"dry-wash.arm.order.table.header.location": "Расположение", "dry-wash.arm.order.table.header.location": "Расположение",
"dry-wash.arm.order.table.empty": "Список пуст", "dry-wash.arm.order.table.empty": "Список пуст",
@ -25,10 +23,6 @@
"dry-wash.arm.master.table.header.phone": "Телефон", "dry-wash.arm.master.table.header.phone": "Телефон",
"dry-wash.arm.master.table.header.actions": "Действия", "dry-wash.arm.master.table.header.actions": "Действия",
"dry-wash.arm.master.table.actionsMenu.delete": "Удалить мастера", "dry-wash.arm.master.table.actionsMenu.delete": "Удалить мастера",
"dry-wash.arm.master.schedule.empty": "Свободен",
"dry-wash.arm.master.editable.aria.cancel": "Отменить изменения",
"dry-wash.arm.master.editable.aria.save": "Сохранить изменения",
"dry-wash.arm.master.editable.aria.edit": "Редактировать",
"dry-wash.arm.master.drawer.title": "Добавить нового мастера", "dry-wash.arm.master.drawer.title": "Добавить нового мастера",
"dry-wash.arm.master.drawer.inputName.label": "ФИО", "dry-wash.arm.master.drawer.inputName.label": "ФИО",
"dry-wash.arm.master.drawer.inputName.placeholder": "Введите ФИО", "dry-wash.arm.master.drawer.inputName.placeholder": "Введите ФИО",
@ -79,10 +73,9 @@
"dry-wash.order-create.car-body-select.options.sports-car": "Спорткар", "dry-wash.order-create.car-body-select.options.sports-car": "Спорткар",
"dry-wash.order-create.car-body-select.options.other": "Другой", "dry-wash.order-create.car-body-select.options.other": "Другой",
"dry-wash.order-create.form.submit-button.label": "Отправить", "dry-wash.order-create.form.submit-button.label": "Отправить",
"dry-wash.order-create.create-order-query.success.title": "Заказ успешно создан",
"dry-wash.order-create.create-order-query.error.title": "Не удалось создать заказ",
"dry-wash.order-view.title": "Ваш заказ", "dry-wash.order-view.title": "Ваш заказ",
"dry-wash.order-view.get-order-query.error.title": "Не удалось загрузить детали заказа №{{number}}", "dry-wash.order-view.error.title": "Ошибка",
"dry-wash.order-view.fetch.error": "Не удалось загрузить детали заказа №{{number}}",
"dry-wash.order-view.details.title": "Заказ №{{number}}", "dry-wash.order-view.details.title": "Заказ №{{number}}",
"dry-wash.order-view.details.owner": "Владелец", "dry-wash.order-view.details.owner": "Владелец",
"dry-wash.order-view.details.car": "Автомобиль", "dry-wash.order-view.details.car": "Автомобиль",

1417
package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -1,6 +1,6 @@
{ {
"name": "dry-wash", "name": "dry-wash",
"version": "0.5.0", "version": "0.3.0",
"description": "<a id=\"readme-top\"></a>", "description": "<a id=\"readme-top\"></a>",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"scripts": { "scripts": {
@ -19,13 +19,12 @@
"dependencies": { "dependencies": {
"@brojs/cli": "^1.6.3", "@brojs/cli": "^1.6.3",
"@chakra-ui/icons": "^2.2.4", "@chakra-ui/icons": "^2.2.4",
"@chakra-ui/react": "^2.10.5", "@chakra-ui/react": "^2.4.2",
"@emotion/react": "^11.4.1", "@emotion/react": "^11.4.1",
"@emotion/styled": "^11.3.0", "@emotion/styled": "^11.3.0",
"@fontsource/open-sans": "^5.1.0", "@fontsource/open-sans": "^5.1.0",
"@lottiefiles/react-lottie-player": "^3.5.4", "@lottiefiles/react-lottie-player": "^3.5.4",
"@pbe/react-yandex-maps": "^1.2.5", "@pbe/react-yandex-maps": "^1.2.5",
"@reduxjs/toolkit": "^2.5.0",
"@types/react": "^18.3.12", "@types/react": "^18.3.12",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"express": "^4.21.1", "express": "^4.21.1",
@ -38,7 +37,6 @@
"react-i18next": "^15.1.1", "react-i18next": "^15.1.1",
"react-icons": "^5.3.0", "react-icons": "^5.3.0",
"react-phone-number-input": "^3.4.9", "react-phone-number-input": "^3.4.9",
"react-redux": "^9.2.0",
"react-router-dom": "^6.27.0" "react-router-dom": "^6.27.0"
}, },
"devDependencies": { "devDependencies": {

View File

@ -1,41 +0,0 @@
import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
import { getConfigValue } from '@brojs/cli';
import { Master } from '../../models/api/master';
type SuccessResponse<Body> = {
success: true;
body: Body;
};
type ErrorResponse = {
success: false;
message: string;
};
type BaseResponse<Body> = SuccessResponse<Body> | ErrorResponse;
export const api = createApi({
reducerPath: 'api',
baseQuery: fetchBaseQuery({ baseUrl: getConfigValue('dry-wash.api') }),
tagTypes: ['Masters'],
endpoints: (builder) => ({
getMasters: builder.query<Master[], void>({
query: () => ({ url: '/arm/masters' }),
transformResponse: (response: BaseResponse<Master[]>) => {
if (response.success) {
return response.body;
}
},
providesTags: ['Masters'],
}),
addMaster: builder.mutation<void, Pick<Master, 'name' | 'phone'>>({
query: (master) => ({
url: '/arm/masters',
method: 'POST',
body: master,
}),
invalidatesTags: ['Masters'],
}),
}),
});

View File

@ -1,14 +0,0 @@
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<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -1,5 +1,4 @@
import { getConfigValue } from '@brojs/cli'; import { getConfigValue } from '@brojs/cli';
import dayjs from 'dayjs';
enum ArmEndpoints { enum ArmEndpoints {
ORDERS = '/arm/orders', ORDERS = '/arm/orders',
@ -10,15 +9,12 @@ const armService = () => {
const endpoint = getConfigValue('dry-wash.api'); const endpoint = getConfigValue('dry-wash.api');
const fetchOrders = async ({ date }: { date: Date }) => { const fetchOrders = async ({ date }: { date: Date }) => {
const startDate = dayjs(date).startOf('day').toISOString();
const endDate = dayjs(date).endOf('day').toISOString();
const response = await fetch(`${endpoint}${ArmEndpoints.ORDERS}`, { const response = await fetch(`${endpoint}${ArmEndpoints.ORDERS}`, {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}, },
body: JSON.stringify({ startDate, endDate }), body: JSON.stringify({ date }),
}); });
if (!response.ok) { if (!response.ok) {
@ -38,102 +34,7 @@ const armService = () => {
return await response.json(); return await response.json();
}; };
const addMaster = async ({ return { fetchOrders, fetchMasters };
name,
phone,
}: {
name: string;
phone: string;
}) => {
const response = await fetch(`${endpoint}${ArmEndpoints.MASTERS}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ name, phone }),
});
if (!response.ok) {
throw new Error(`Failed to fetch masters: ${response.status}`);
}
return await response.json();
};
const deleteMaster = async ({ id }: { id: string }) => {
const response = await fetch(`${endpoint}${ArmEndpoints.MASTERS}/${id}`, {
method: 'DELETE',
});
if (!response.ok) {
throw new Error(`Failed to fetch masters: ${response.status}`);
}
return await response.json();
};
const updateOrders = async ({
id,
status,
notes,
masterId,
}: {
id: string;
status?: string;
notes?: string;
masterId?: string;
}) => {
const body = JSON.stringify({ status, notes, masterId });
const response = await fetch(`${endpoint}${ArmEndpoints.ORDERS}/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body,
});
if (!response.ok) {
throw new Error(`Failed to fetch update masters: ${response.status}`);
}
return await response.json();
};
const updateMaster = async ({
id,
name,
phone,
}: {
id: string;
name?: string;
phone?: string;
}) => {
const body = JSON.stringify({ name, phone });
const response = await fetch(`${endpoint}${ArmEndpoints.MASTERS}/${id}`, {
method: 'PATCH',
headers: {
'Content-Type': 'application/json',
},
body,
});
if (!response.ok) {
throw new Error(`Failed to fetch update masters: ${response.status}`);
}
return await response.json();
};
return {
fetchOrders,
fetchMasters,
addMaster,
deleteMaster,
updateMaster,
updateOrders,
};
}; };
export { armService, ArmEndpoints }; export { armService, ArmEndpoints };

View File

@ -1 +0,0 @@
export * from './landing';

25
src/api/landing.ts Normal file
View File

@ -0,0 +1,25 @@
import { getConfigValue } from '@brojs/cli';
import { Order } from '../models/landing';
enum LandingEndpoints {
ORDER_VIEW = '/order'
}
const LandingService = () => {
const endpoint = getConfigValue('dry-wash.api');
const fetchOrder = async (orderId: Order.Id) => {
const response = await fetch(`${endpoint}${LandingEndpoints.ORDER_VIEW}/${orderId}`);
if (!response.ok) {
throw new Error(`Failed to fetch order: ${response.status}`);
}
return await response.json();
};
return { fetchOrder };
};
export { LandingService, LandingEndpoints };

View File

@ -1,107 +0,0 @@
import { getConfigValue } from '@brojs/cli';
import { useEffect, useState } from 'react';
import { CreateOrder, GetOrder } from '../models/api';
import { QueryState, Trigger } from './types';
enum LandingEndpoints {
ORDER = '/order',
ORDER_CREATE = '/order/create',
}
const endpoint = getConfigValue('dry-wash.api');
const useCreateOrderMutation = <D extends CreateOrder.Response>(): [
Trigger<CreateOrder.Params, QueryState<D>['data']>,
QueryState<D>,
] => {
const [isLoading, setIsLoading] = useState<QueryState<D>['isLoading']>(false);
const [isSuccess, setIsSuccess] = useState<QueryState<D>['isSuccess']>();
const [data, setData] = useState<QueryState<D>['data']>();
const [isError, setIsError] = useState<QueryState<D>['isError']>();
const [error, setError] = useState<QueryState<D>['error']>();
const createOrder = async ({ body }: CreateOrder.Params) => {
setIsLoading(true);
try {
const response = await fetch(
`${endpoint}${LandingEndpoints.ORDER_CREATE}`,
{
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
},
);
if (!response.ok) {
const errorResponseObject =
(await response.json()) as QueryState<D>['error'];
setIsError(true);
setError(errorResponseObject);
throw errorResponseObject;
}
const dataResponseObject =
(await response.json()) as QueryState<D>['data'];
setIsSuccess(true);
setData(dataResponseObject);
return dataResponseObject;
} catch (error) {
setIsError(true);
setError(error);
throw error;
} finally {
setIsLoading(false);
}
};
return [createOrder, { isLoading, isSuccess, data, isError, error }];
};
const useGetOrderQuery = <D extends GetOrder.Response>({
orderId,
}: GetOrder.Params): QueryState<D> => {
const [isLoading, setIsLoading] = useState<QueryState<D>['isLoading']>(true);
const [isSuccess, setIsSuccess] = useState<QueryState<D>['isSuccess']>();
const [data, setData] = useState<QueryState<D>['data']>();
const [isError, setIsError] = useState<QueryState<D>['isError']>();
const [error, setError] = useState<QueryState<D>['error']>();
useEffect(() => {
(async () => {
try {
const response = await fetch(
`${endpoint}${LandingEndpoints.ORDER}/${orderId}`,
);
if (!response.ok) {
const errorResponseObject =
(await response.json()) as QueryState<D>['error'];
setIsError(true);
setError(errorResponseObject);
throw errorResponseObject;
}
const dataResponseObject =
(await response.json()) as QueryState<D>['data'];
setIsSuccess(true);
setData(dataResponseObject);
} catch (error) {
setIsError(true);
setError(error);
throw error;
} finally {
setIsLoading(false);
}
})();
}, []);
return { isLoading, isSuccess, data, isError, error };
};
export { useCreateOrderMutation, useGetOrderQuery };

View File

@ -1,22 +0,0 @@
export type QueryData<D> = {
success: true;
body: D;
};
export type QueryErrorData = {
success: false;
error: string;
};
export type QueryState<D> = {
isLoading: boolean;
isSuccess: boolean;
data: QueryData<D>;
isError: boolean;
error: {
status: number;
data: QueryErrorData;
};
};
export type Trigger<P, D> = (params: P) => Promise<D>;

View File

@ -1,15 +1,12 @@
import React from 'react'; import React from 'react';
import { BrowserRouter } from 'react-router-dom'; import { BrowserRouter } from 'react-router-dom';
import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react'; import { ChakraProvider, theme as chakraTheme } from '@chakra-ui/react';
import { Provider } from 'react-redux';
import Routers from './routes'; import Routers from './routes';
import ErrorBoundary from './components/ErrorBoundary'; import ErrorBoundary from './components/ErrorBoundary';
import { store } from './__data__/store';
const App = () => { const App = () => {
return ( return (
<Provider store={store}>
<ChakraProvider theme={chakraTheme}> <ChakraProvider theme={chakraTheme}>
<ErrorBoundary> <ErrorBoundary>
<BrowserRouter> <BrowserRouter>
@ -17,7 +14,6 @@ const App = () => {
</BrowserRouter> </BrowserRouter>
</ErrorBoundary> </ErrorBoundary>
</ChakraProvider> </ChakraProvider>
</Provider>
); );
}; };

View File

@ -1,119 +0,0 @@
import React, { useState } from 'react';
import {
Editable,
EditableInput,
EditablePreview,
Flex,
IconButton,
Input,
useEditableControls,
ButtonGroup,
Stack,
useToast,
} from '@chakra-ui/react';
import { CheckIcon, CloseIcon, EditIcon } from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next';
interface EditableWrapperProps {
value: string;
onSubmit: ({
id,
name,
phone,
}: {
id: string;
name?: string;
phone?: string;
}) => Promise<unknown>;
as: 'phone' | 'name';
id: string;
}
const EditableWrapper = ({ value, onSubmit, as, id }: EditableWrapperProps) => {
const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.master.editable',
});
const toast = useToast();
const [currentValue, setCurrentValue] = useState<string>(value);
const handleSubmit = async (newValue: string) => {
if (currentValue === newValue) return;
try {
await onSubmit({ id, [as]: newValue });
setCurrentValue(newValue);
toast({
title: 'Успешно!',
description: 'Данные обновлены.',
status: 'success',
duration: 2000,
isClosable: true,
position: 'top-right',
});
} catch (error) {
toast({
title: 'Ошибка!',
description: 'Не удалось обновить данные.',
status: 'error',
duration: 2000,
isClosable: true,
position: 'top-right',
});
console.error('Ошибка при обновлении данных:', error);
}
};
function EditableControls() {
const {
isEditing,
getSubmitButtonProps,
getCancelButtonProps,
getEditButtonProps,
} = useEditableControls();
return isEditing ? (
<ButtonGroup justifyContent='center' size='sm'>
<IconButton
aria-label={t('aria.save')}
icon={<CheckIcon />}
{...getSubmitButtonProps()}
/>
<IconButton
aria-label={t('aria.cancel')}
icon={<CloseIcon />}
{...getCancelButtonProps()}
/>
</ButtonGroup>
) : (
<Flex justifyContent='center'>
<IconButton
aria-label={t('aria.edit')}
size='sm'
icon={<EditIcon />}
{...getEditButtonProps()}
/>
</Flex>
);
}
return (
<Editable
textAlign='center'
defaultValue={currentValue}
fontSize='2xl'
isPreviewFocusable={false}
onSubmit={handleSubmit}
>
<Stack direction={['column', 'row']} spacing='15px'>
<EditablePreview />
<Input as={EditableInput} />
<EditableControls />
</Stack>
</Editable>
);
};
export default EditableWrapper;

View File

@ -5,54 +5,20 @@ import {
MenuList, MenuList,
MenuItem, MenuItem,
IconButton, IconButton,
useToast,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { EditIcon } from '@chakra-ui/icons'; import { EditIcon } from '@chakra-ui/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { armService } from '../../api/arm'; const MasterActionsMenu = () => {
interface MasterActionsMenu {
id: string;
}
const MasterActionsMenu = ({ id }: MasterActionsMenu) => {
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.master.table.actionsMenu', keyPrefix: 'dry-wash.arm.master.table.actionsMenu',
}); });
const { deleteMaster } = armService();
const toast = useToast();
const handleClickDelete = async () => {
try {
await deleteMaster({ id });
toast({
title: 'Мастер удалён.',
description: `Мастер с ID "${id}" успешно удалён.`,
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
} catch (error) {
toast({
title: 'Ошибка удаления мастера.',
description: 'Не удалось удалить мастера. Попробуйте ещё раз.',
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
console.error(error);
}
};
return ( return (
<Menu> <Menu>
<MenuButton icon={<EditIcon />} as={IconButton} variant='outline' /> <MenuButton icon={<EditIcon />} as={IconButton} variant='outline' />
<MenuList> <MenuList>
<MenuItem onClick={handleClickDelete}>{t('delete')}</MenuItem> <MenuItem>{t('delete')}</MenuItem>
</MenuList> </MenuList>
</Menu> </Menu>
); );

View File

@ -1,4 +1,4 @@
import React, { useEffect, useState } from 'react'; import React, { useState } from 'react';
import { import {
Button, Button,
FormControl, FormControl,
@ -11,61 +11,16 @@ import {
DrawerFooter, DrawerFooter,
DrawerHeader, DrawerHeader,
DrawerOverlay, DrawerOverlay,
useToast,
InputGroup,
InputLeftElement,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { PhoneIcon } from '@chakra-ui/icons';
import { api } from '../../__data__/service/api';
const MasterDrawer = ({ isOpen, onClose }) => { const MasterDrawer = ({ isOpen, onClose }) => {
const [addMaster, { error, isSuccess }] = api.useAddMasterMutation();
const toast = useToast();
const [newMaster, setNewMaster] = useState({ name: '', phone: '' }); const [newMaster, setNewMaster] = useState({ name: '', phone: '' });
const handleSave = async () => { const handleSave = () => {
const trimMaster = { console.log(`Сохранение мастера: ${newMaster}`);
phone: newMaster.phone.trim(),
name: newMaster.name.trim(),
};
if (trimMaster.name === '' || trimMaster.phone === '') {
return;
}
addMaster(trimMaster);
};
useEffect(() => {
if (isSuccess) {
toast({
title: 'Мастер создан.',
description: `Мастер "${newMaster.name}" успешно добавлен.`,
status: 'success',
duration: 5000,
isClosable: true,
position: 'top-right',
});
onClose(); onClose();
} };
}, [isSuccess]);
useEffect(() => {
if (error) {
toast({
title: 'Ошибка при создании мастера.',
description: 'Не удалось добавить мастера. Попробуйте еще раз.',
status: 'error',
duration: 5000,
isClosable: true,
position: 'top-right',
});
console.error(error);
}
}, [error]);
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.master.drawer', keyPrefix: 'dry-wash.arm.master.drawer',
@ -81,7 +36,6 @@ const MasterDrawer = ({ isOpen, onClose }) => {
<FormControl mb='4'> <FormControl mb='4'>
<FormLabel>{t('inputName.label')}</FormLabel> <FormLabel>{t('inputName.label')}</FormLabel>
<Input <Input
// isInvalid
value={newMaster.name} value={newMaster.name}
onChange={(e) => onChange={(e) =>
setNewMaster({ ...newMaster, name: e.target.value }) setNewMaster({ ...newMaster, name: e.target.value })
@ -91,20 +45,13 @@ const MasterDrawer = ({ isOpen, onClose }) => {
</FormControl> </FormControl>
<FormControl> <FormControl>
<FormLabel> {t('inputPhone.label')}</FormLabel> <FormLabel> {t('inputPhone.label')}</FormLabel>
<InputGroup>
<InputLeftElement pointerEvents='none'>
<PhoneIcon color='gray.300' />
</InputLeftElement>
<Input <Input
// isInvalid
value={newMaster.phone} value={newMaster.phone}
onChange={(e) => onChange={(e) =>
setNewMaster({ ...newMaster, phone: e.target.value }) setNewMaster({ ...newMaster, phone: e.target.value })
} }
placeholder={t('inputPhone.placeholder')} placeholder={t('inputPhone.placeholder')}
/> />
</InputGroup>
</FormControl> </FormControl>
</DrawerBody> </DrawerBody>
<DrawerFooter> <DrawerFooter>

View File

@ -1,51 +1,40 @@
import React from 'react'; import React from 'react';
import { Badge, Stack, Td, Tr, Text } from '@chakra-ui/react'; import { Badge, Link, Stack, Td, Tr } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next';
import MasterActionsMenu from '../MasterActionsMenu'; import MasterActionsMenu from '../MasterActionsMenu';
import { getTimeSlot } from '../../lib'; import { getTimeSlot } from '../../lib/date-helpers';
import EditableWrapper from '../Editable/Editable';
import { armService } from '../../api/arm';
const MasterItem = ({ name, phone, id, schedule }) => { export interface Schedule {
const { updateMaster } = armService(); id: string;
const { t } = useTranslation('~', { startWashTime: string;
keyPrefix: 'dry-wash.arm.master', endWashTime: string;
}); }
export type MasterProps = {
id: string;
name: string;
schedule: Schedule[];
phone: string;
};
const MasterItem = ({ name, schedule, phone }) => {
return ( return (
<Tr> <Tr>
<Td>{name}</Td>
<Td> <Td>
<EditableWrapper
id={id}
as={'name'}
value={name}
onSubmit={updateMaster}
/>
</Td>
<Td>
{schedule?.length > 0 ? (
<Stack direction='row'> <Stack direction='row'>
{schedule?.map(({ startWashTime, endWashTime }, index: number) => ( {schedule.map(({ startWashTime, endWashTime }, index) => (
<Badge colorScheme={'green'} key={index}> <Badge colorScheme={'green'} key={index}>
{getTimeSlot(startWashTime, endWashTime)} {getTimeSlot(startWashTime, endWashTime)}
</Badge> </Badge>
))} ))}
</Stack> </Stack>
) : (
<Text color='gray.500'>{t('schedule.empty')}</Text>
)}
</Td> </Td>
<Td> <Td>
<EditableWrapper <Link href='tel:'>{phone}</Link>
id={id}
as={'phone'}
value={phone}
onSubmit={updateMaster}
/>
</Td> </Td>
<Td> <Td>
<MasterActionsMenu id={id} /> <MasterActionsMenu />
</Td> </Td>
</Tr> </Tr>
); );

View File

@ -1,4 +1,4 @@
import React, { useEffect } from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Box, Box,
Heading, Heading,
@ -19,7 +19,8 @@ import { useTranslation } from 'react-i18next';
import MasterItem from '../MasterItem'; import MasterItem from '../MasterItem';
import MasterDrawer from '../MasterDrawer'; import MasterDrawer from '../MasterDrawer';
import { api } from '../../__data__/service/api'; import { armService } from '../../api/arm';
import { MasterProps } from '../MasterItem/MasterItem';
const TABLE_HEADERS = [ const TABLE_HEADERS = [
'name' as const, 'name' as const,
@ -35,23 +36,35 @@ const Masters = () => {
keyPrefix: 'dry-wash.arm.master', keyPrefix: 'dry-wash.arm.master',
}); });
const { const [masters, setMasters] = useState<MasterProps[]>([]);
data: masters, const [loading, setLoading] = useState(false);
error, const [error, setError] = useState<string | null>(null);
isLoading,
isSuccess, const { fetchMasters } = armService();
} = api.useGetMastersQuery();
useEffect(() => { useEffect(() => {
if (error) { const loadMasters = async () => {
setLoading(true);
try {
const data = await fetchMasters();
setMasters(data.body);
} catch (err) {
setError(err.message);
toast({ toast({
title: t('error.title'), title: t('error.title'),
status: 'error', status: 'error',
duration: 5000,
isClosable: true, isClosable: true,
position: 'bottom-right', position: 'bottom-right',
}); });
} finally {
setLoading(false);
} }
}, [error]); };
loadMasters();
}, [toast, t]);
return ( return (
<Box p='8'> <Box p='8'>
@ -70,21 +83,22 @@ const Masters = () => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{isLoading && ( {loading && (
<Tr> <Tr>
<Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'> <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'>
<Spinner size='lg' /> <Spinner size='lg' />
</Td> </Td>
</Tr> </Tr>
)} )}
{isSuccess && masters.length === 0 && ( {!loading && masters.length === 0 && !error && (
<Tr> <Tr>
<Td colSpan={TABLE_HEADERS.length}> <Td colSpan={TABLE_HEADERS.length}>
<Text>{t('table.empty')}</Text> <Text>{t('table.empty')}</Text>
</Td> </Td>
</Tr> </Tr>
)} )}
{isSuccess && {!loading &&
!error &&
masters.map((master, index) => ( masters.map((master, index) => (
<MasterItem key={index} {...master} /> <MasterItem key={index} {...master} />
))} ))}

View File

@ -1,11 +1,9 @@
import React, { ChangeEvent, useState } from 'react'; import React, { useState } from 'react';
import { Td, Tr, Link, Select } from '@chakra-ui/react'; import { Td, Tr, Link, Select } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
import { getTimeSlot } from '../../lib'; import { getTimeSlot } from '../../lib/date-helpers';
import { Master } from '../../models/api/master';
import { armService } from '../../api/arm';
const statuses = [ const statuses = [
'pending' as const, 'pending' as const,
@ -26,20 +24,6 @@ export type OrderProps = {
status?: GetArrItemType<typeof statuses>; status?: GetArrItemType<typeof statuses>;
phone?: string; phone?: string;
location?: string; location?: string;
master: Master;
notes: '';
allMasters: Master[];
id: string;
};
type Status = (typeof statuses)[number];
const statusColors: Record<Status, string> = {
pending: 'yellow.100',
progress: 'blue.100',
working: 'orange.100',
canceled: 'red.100',
complete: 'green.100',
}; };
const OrderItem = ({ const OrderItem = ({
@ -50,53 +34,23 @@ const OrderItem = ({
status, status,
phone, phone,
location, location,
master,
allMasters,
id,
}: OrderProps) => { }: OrderProps) => {
const { updateOrders } = armService();
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.order', keyPrefix: 'dry-wash.arm.order',
}); });
const [statusSelect, setStatus] = useState(status); const [statusSelect, setStatus] = useState(status);
const bgColor = statusColors[statusSelect];
const [masterSelect, setMaster] = useState(master?.name);
const handelChangeMasters = (e: ChangeEvent<HTMLSelectElement>) => {
const masterName = e.target.value;
const selectedMaster = allMasters.find(
(master) => master.name === masterName,
);
if (selectedMaster) {
setMaster(masterName);
updateOrders({ id, masterId: selectedMaster.id });
} else {
console.error('Master not found');
}
};
const handeChangeStatus = (e: ChangeEvent<HTMLSelectElement>) => {
const status = e.target.value;
updateOrders({ id, status });
setStatus(e.target.value as OrderProps['status']);
};
return ( return (
<Tr> <Tr>
<Td>{carNumber}</Td> <Td>{carNumber}</Td>
<Td> <Td>{getTimeSlot(startWashTime, endWashTime)}</Td>
{dayjs(orderDate).format('DD.MM.YYYY')} <br /> <Td>{dayjs(orderDate).format('DD.MM.YYYY')}</Td>
{getTimeSlot(startWashTime, endWashTime)}
</Td>
<Td> <Td>
<Select <Select
value={statusSelect} value={statusSelect}
onChange={handeChangeStatus} onChange={(e) => setStatus(e.target.value as OrderProps['status'])}
placeholder={t(`status.placeholder`)} placeholder={t(`status.placeholder`)}
bg={bgColor}
> >
{statuses.map((status) => ( {statuses.map((status) => (
<option key={status} value={status}> <option key={status} value={status}>
@ -105,19 +59,6 @@ const OrderItem = ({
))} ))}
</Select> </Select>
</Td> </Td>
<Td>
<Select
value={masterSelect}
onChange={handelChangeMasters}
placeholder={t(`master.placeholder`)}
>
{allMasters.map((item) => (
<option key={item.id} value={item.name}>
{item.name}
</option>
))}
</Select>
</Td>
<Td> <Td>
<Link href='tel:'>{phone}</Link> <Link href='tel:'>{phone}</Link>
</Td> </Td>

View File

@ -19,13 +19,12 @@ import OrderItem from '../OrderItem';
import { OrderProps } from '../OrderItem/OrderItem'; import { OrderProps } from '../OrderItem/OrderItem';
import { armService } from '../../api/arm'; import { armService } from '../../api/arm';
import DateNavigator from '../DateNavigator'; import DateNavigator from '../DateNavigator';
import { Master } from '../../models/api/master';
const TABLE_HEADERS = [ const TABLE_HEADERS = [
'carNumber' as const, 'carNumber' as const,
'washingTime' as const,
'orderDate' as const, 'orderDate' as const,
'status' as const, 'status' as const,
'masters' as const,
'telephone' as const, 'telephone' as const,
'location' as const, 'location' as const,
]; ];
@ -36,29 +35,21 @@ const Orders = () => {
}); });
const { fetchOrders } = armService(); const { fetchOrders } = armService();
const { fetchMasters } = armService();
const toast = useToast(); const toast = useToast();
const [orders, setOrders] = useState<OrderProps[]>([]); const [orders, setOrders] = useState<OrderProps[]>([]);
const [allMasters, setAllMasters] = useState<Master[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [currentDate, setCurrentDate] = useState(new Date()); const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => { useEffect(() => {
const loadData = async () => { const loadOrders = async () => {
setLoading(true); setLoading(true);
setError(null);
try { try {
const [ordersData, mastersData] = await Promise.all([ const data = await fetchOrders({ date: currentDate });
fetchOrders({ date: currentDate }), setOrders(data.body);
fetchMasters(),
]);
setOrders(ordersData.body);
setAllMasters(mastersData.body);
} catch (err) { } catch (err) {
setError(err.message); setError(err.message);
toast({ toast({
@ -73,8 +64,8 @@ const Orders = () => {
} }
}; };
loadData(); loadOrders();
}, [currentDate, toast, t]); }, [toast, t, currentDate]);
return ( return (
<Box p='8'> <Box p='8'>
@ -121,7 +112,6 @@ const Orders = () => {
!error && !error &&
orders.map((order, index) => ( orders.map((order, index) => (
<OrderItem <OrderItem
allMasters={allMasters}
key={index} key={index}
{...order} {...order}
status={order.status as OrderProps['status']} status={order.status as OrderProps['status']}

View File

@ -18,9 +18,7 @@ import { CarBodySelectOption } from './types';
export const CarBodySelect = forwardRef<HTMLInputElement, InputProps>( export const CarBodySelect = forwardRef<HTMLInputElement, InputProps>(
function CarBodySelect(props, ref) { function CarBodySelect(props, ref) {
const initialOption = carBodySelectOptions.find(({ value }) => value === Number(props.value)); const [selected, setSelected] = useState<Partial<CarBodySelectOption>>({});
const [selected, setSelected] = useState<Partial<CarBodySelectOption>>(initialOption);
const handleOptionClick = (option: CarBodySelectOption) => { const handleOptionClick = (option: CarBodySelectOption) => {
setSelected(option); setSelected(option);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment // eslint-disable-next-line @typescript-eslint/ban-ts-comment

View File

@ -15,7 +15,7 @@ export const CarNumberInput = forwardRef<HTMLInputElement, InputProps>(
// @ts-ignore // @ts-ignore
onChange?.(formattedValue); onChange?.(formattedValue);
}} }}
maxLength={12} maxLength={8}
/> />
); );
}, },

View File

@ -3,11 +3,11 @@ const VALID_LETTER = 'а|в|е|к|м|н|о|р|с|т|у|х';
const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi'); const invalidCharsRe = new RegExp(`[^(${VALID_LETTER})0-9]`, 'gi');
const cleanValue = (value: string) => value.replace(invalidCharsRe, ''); const cleanValue = (value: string) => value.replace(invalidCharsRe, '');
const validCarNumberInputRe = new RegExp(`^([${VALID_LETTER}]{1}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))([${VALID_LETTER}]{1,2}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))$`, 'gi'); const validCarNumberInputRe = new RegExp(`^([${VALID_LETTER}]{1}|$)((?:[0-9]|$)(?:[0-9]|$)(?:[0-9]|$))([${VALID_LETTER}]{1,2}|$)$`, 'gi');
const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue); const isValidInput = (cleanedValue: string) => validCarNumberInputRe.test(cleanedValue);
const formatAsCarNumber = (cleanedValue: string) => { const formatAsCarNumber = (cleanedValue: string) => {
return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3, p4) => [p1, p2, p3, p4].join(' ')).toUpperCase(); return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3) => [p1, p2, p3].join(' ')).toUpperCase();
}; };
const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1); const getWithoutLastChar = (value: string) => value.substring(0, value.length - 1);
@ -25,7 +25,7 @@ export const handleInputChange = (value: string | undefined | null) => {
return getWithoutLastChar(value).trim(); return getWithoutLastChar(value).trim();
}; };
const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}[0-9]{2,3}$`, 'i'); const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}$`, 'i');
export const isValidCarNumber = (value: string) => { export const isValidCarNumber = (value: string) => {
const cleanedValue = cleanValue(value); const cleanedValue = cleanValue(value);

View File

@ -1,4 +1,7 @@
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { InputProps, SelectProps } from "@chakra-ui/react";
import { Order } from "../../../models/landing";
import { FormFieldProps } from "./field"; import { FormFieldProps } from "./field";
import { OrderFormValues } from "./types"; import { OrderFormValues } from "./types";
@ -27,3 +30,38 @@ export const useGetValidationRules = () => {
}, },
} satisfies Record<string, FormFieldProps['rules']>; } satisfies Record<string, FormFieldProps['rules']>;
}; };
const removeAllSpaces = (str: string) => str.replace(/\s+/g, '');
const getValidCarBodyStyle = (fieldValue: string) => {
const carBodyAsNumber = Number(fieldValue);
return Number.isNaN(carBodyAsNumber) ? undefined : carBodyAsNumber;
};
export const formatFormValues = ({ phone, carNumber, carBody, carColor, carLocation, availableDatetimeBegin, availableDatetimeEnd }: OrderFormValues): Order.Create => {
return {
customer: {
phone
},
car: {
number: removeAllSpaces(carNumber),
body: getValidCarBodyStyle(carBody),
color: carColor
},
washing: {
location: carLocation,
begin: availableDatetimeBegin,
end: availableDatetimeEnd,
}
};
};
export const onSubmit = (values: OrderFormValues) => {
return new Promise((resolve) => {
console.log(formatFormValues(values));
resolve(formatFormValues(values));
});
};
export const inputCommonStyles: Partial<InputProps & SelectProps> = {
};

View File

@ -1,2 +1 @@
export type { OrderFormValues, OrderFormProps } from './types';
export { OrderForm } from './order-form'; export { OrderForm } from './order-form';

View File

@ -1,4 +1,4 @@
import React from 'react'; import React, { FC } from 'react';
import { useForm } from 'react-hook-form'; import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react'; import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react';
@ -7,19 +7,14 @@ import { CarBodySelect } from './car-body';
import { CarColorInput } from './car-color'; import { CarColorInput } from './car-color';
import { CarNumberInput } from './car-number'; import { CarNumberInput } from './car-number';
import { FormInputField, FormControllerField } from './field'; import { FormInputField, FormControllerField } from './field';
import { OrderFormProps, OrderFormValues } from './types'; import { OrderFormValues } from './types';
import { PhoneInput } from './phone'; import { PhoneInput } from './phone';
import { SubmitButton } from './submit'; import { SubmitButton } from './submit';
import { defaultValues, useGetValidationRules } from './helper'; import { defaultValues, onSubmit, useGetValidationRules } from './helper';
import { DateTimeInput } from './date-time'; import { DateTimeInput } from './date-time';
import { import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location';
LocationInput,
MapComponent,
StringLocation,
YMapsProvider,
} from './location';
export const OrderForm = ({ onSubmit, loading }: OrderFormProps) => { export const OrderForm: FC = () => {
const { const {
handleSubmit, handleSubmit,
control, control,
@ -128,7 +123,7 @@ export const OrderForm = ({ onSubmit, loading }: OrderFormProps) => {
}} }}
/> />
</YMapsProvider> </YMapsProvider>
<SubmitButton isLoading={isSubmitting || loading} mt={4} /> <SubmitButton isLoading={isSubmitting} mt={4} />
</VStack> </VStack>
</Box> </Box>
); );

View File

@ -1,5 +1,3 @@
import { SubmitHandler } from "react-hook-form";
export type OrderFormValues = { export type OrderFormValues = {
phone: string; phone: string;
carNumber: string; carNumber: string;
@ -9,8 +7,3 @@ export type OrderFormValues = {
availableDatetimeBegin: string; availableDatetimeBegin: string;
availableDatetimeEnd: string; availableDatetimeEnd: string;
}; };
export type OrderFormProps = {
onSubmit: SubmitHandler<OrderFormValues>;
loading: boolean;
};

View File

@ -17,19 +17,7 @@ import { carBodySelectOptions } from '../../order-form/form/car-body/helper';
import { OrderStatus } from './status'; import { OrderStatus } from './status';
type OrderDetailsProps = Pick< type OrderDetailsProps = Order.View;
Order.View,
| 'id'
| 'status'
| 'phone'
| 'carNumber'
| 'carBody'
| 'carColor'
| 'location'
| 'startWashTime'
| 'endWashTime'
| 'created'
>;
export const OrderDetails: FC<OrderDetailsProps> = ({ export const OrderDetails: FC<OrderDetailsProps> = ({
id, id,
@ -39,8 +27,8 @@ export const OrderDetails: FC<OrderDetailsProps> = ({
carBody, carBody,
carColor, carColor,
location, location,
startWashTime, datetimeBegin,
endWashTime, datetimeEnd,
}) => { }) => {
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.order-view.details', keyPrefix: 'dry-wash.order-view.details',
@ -87,8 +75,8 @@ export const OrderDetails: FC<OrderDetailsProps> = ({
{ {
label: t('datetime-range'), label: t('datetime-range'),
value: [ value: [
formatDatetime(startWashTime), formatDatetime(datetimeBegin),
formatDatetime(endWashTime), formatDatetime(datetimeEnd),
].join(' - '), ].join(' - '),
}, },
].map(({ label, value }, i) => ( ].map(({ label, value }, i) => (

View File

@ -1,31 +1,14 @@
import React, { ComponentType, FC, PropsWithChildren } from 'react'; import React, { FC, PropsWithChildren } from 'react';
import { ChakraProvider } from '@chakra-ui/react'; import { ChakraProvider } from '@chakra-ui/react';
import { default as landingTheme } from './theme-config'; import { default as landingTheme } from './theme-config';
import Fonts from './Fonts'; import Fonts from './Fonts';
import { toastOptions } from './toast-options';
export const LandingThemeProvider: FC<PropsWithChildren> = ({ children }) => { export const LandingThemeProvider: FC<PropsWithChildren> = ({ children }) => {
return ( return (
<ChakraProvider theme={landingTheme} toastOptions={toastOptions}> <ChakraProvider theme={landingTheme}>
<Fonts /> <Fonts />
{children} {children}
</ChakraProvider> </ChakraProvider>
); );
}; };
export function withLandingThemeProvider<T extends JSX.IntrinsicAttributes>(WrappedComponent: ComponentType<T>) {
const displayName = WrappedComponent.displayName || WrappedComponent.name || 'Component';
const ComponentWithLandingThemeProvider = (props: T) => {
return (
<LandingThemeProvider>
<WrappedComponent {...props} />
</LandingThemeProvider>
);
};
ComponentWithLandingThemeProvider.displayName = `withLandingThemeProvider(${displayName})`;
return ComponentWithLandingThemeProvider;
}

View File

@ -1 +1 @@
export { LandingThemeProvider, withLandingThemeProvider } from './LandingThemeProvider'; export { LandingThemeProvider } from './LandingThemeProvider';

View File

@ -1,8 +0,0 @@
import { ToastProviderProps } from "@chakra-ui/react";
export const toastOptions: ToastProviderProps = {
defaultOptions: {
position: 'top-right',
isClosable: true,
}
};

View File

@ -1 +1 @@
export * from './order'; export * from './order-view';

View File

@ -1,12 +0,0 @@
export interface Schedule {
id: string;
startWashTime: string;
endWashTime: string;
}
export type Master = {
id: string;
name: string;
phone: string;
schedule: Schedule[];
};

View File

@ -0,0 +1,14 @@
import { Order } from "../landing";
export type FetchOrderQueryResponse = {
id: string;
orderDate: string;
carNumber: string;
carBody: number;
carColor?: string;
startWashTime: string;
endWashTime: string;
status: Order.Status;
phone: string;
location: string;
};

View File

@ -1,18 +0,0 @@
/* eslint-disable @typescript-eslint/no-namespace */
import { Order } from "../landing";
export namespace CreateOrder {
export type Response = {
id: Order.Id
};
export type Params = {
body: Order.Create
};
};
export namespace GetOrder {
export type Response = Order.View;
export type Params = {
orderId: Order.Id
};
};

View File

@ -1 +0,0 @@
export type IsoDate = string; // YYYY-MM-DDThh:mm:ss.mmmZ

View File

@ -1,4 +1,4 @@
export type RegistrationNumber = string; // А012ВЕ16 export type RegistrationNumber = string; // А012ВЕ
export type Color = string; // #000000 export type Color = string; // #000000

View File

@ -1,5 +1,3 @@
import { IsoDate } from "../common";
import { Car, Customer, Washing } from "."; import { Car, Customer, Washing } from ".";
export type Id = string; export type Id = string;
@ -27,16 +25,14 @@ export type Create = {
}; };
export type View = { export type View = {
id: Id;
orderDate: string,
status: Status,
phone: Customer.PhoneNumber; phone: Customer.PhoneNumber;
carNumber: Car.RegistrationNumber; carNumber: Car.RegistrationNumber;
carBody: Car.BodyStyle; carBody: Car.BodyStyle;
carColor?: Car.Color; carColor?: Car.Color;
location: Washing.Location; location: Washing.Location;
startWashTime: Washing.AvailableBeginDateTime; datetimeBegin: Washing.AvailableBeginDateTime;
endWashTime: Washing.AvailableEndDateTime; datetimeEnd: Washing.AvailableEndDateTime;
status: Status,
notes: string;
created: IsoDate;
updated: IsoDate;
id: Id;
}; };

View File

@ -1,7 +1,5 @@
import { IsoDate } from "../common"; export type Location = string; // ?
export type Location = string; // 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500 export type AvailableBeginDateTime = string; // YYYY-MM-DDThh:mm
export type AvailableBeginDateTime = IsoDate; export type AvailableEndDateTime = string; // YYYY-MM-DDThh:mm
export type AvailableEndDateTime = IsoDate;

View File

@ -1,11 +1,12 @@
import React, { useEffect, useState } from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import { AbsoluteCenter, Box, Spinner } from '@chakra-ui/react'; import { AbsoluteCenter, Spinner } from '@chakra-ui/react';
import LayoutArm from '../../components/LayoutArm'; import LayoutArm from '../../components/LayoutArm';
import authLogin from '../../keycloak'; import authLogin from '../../keycloak';
import { URLs } from '../../__data__/urls'; import { URLs } from '../../__data__/urls';
const Page = () => { const Page = () => {
const [user, setUser] = useState(null); const [user, setUser] = useState(null);
@ -19,11 +20,9 @@ const Page = () => {
if (!user) if (!user)
return ( return (
<Box position='relative' height='100vh'>
<AbsoluteCenter> <AbsoluteCenter>
<Spinner /> <Spinner />
</AbsoluteCenter> </AbsoluteCenter>
</Box>
); );
return <LayoutArm />; return <LayoutArm />;

View File

@ -1,29 +0,0 @@
import dayjs from "dayjs";
import { Order } from "../../models/landing";
import { OrderFormValues } from "../../components/order-form";
const removeAllSpaces = (str: string) => str.replace(/\s+/g, '');
const getValidCarBodyStyle = (fieldValue: string) => {
const carBodyAsNumber = Number(fieldValue);
return Number.isNaN(carBodyAsNumber) ? undefined : carBodyAsNumber;
};
export const formatFormValues = ({ phone, carNumber, carBody, carColor, carLocation, availableDatetimeBegin, availableDatetimeEnd }: OrderFormValues): Order.Create => {
return {
customer: {
phone
},
car: {
number: removeAllSpaces(carNumber),
body: getValidCarBodyStyle(carBody),
color: carColor
},
washing: {
location: carLocation,
begin: dayjs(availableDatetimeBegin).toISOString(),
end: dayjs(availableDatetimeEnd).toISOString(),
}
};
};

View File

@ -1,44 +1,17 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Container, Heading, useToast, VStack } from '@chakra-ui/react'; import { Container, Heading, VStack } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { withLandingThemeProvider } from '../../containers'; import { LandingThemeProvider } from '../../containers';
import { OrderForm, OrderFormProps } from '../../components/order-form'; import { OrderForm } from '../../components/order-form';
import { useCreateOrderMutation } from '../../api';
import { URLs } from '../../__data__/urls';
import { formatFormValues } from './helper';
const Page: FC = () => { const Page: FC = () => {
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.order-create', keyPrefix: 'dry-wash.order-create',
}); });
const [createOrder, createOrderMutation] = useCreateOrderMutation();
const toast = useToast();
const navigate = useNavigate();
const onOrderFormSubmit: OrderFormProps['onSubmit'] = (values) => {
createOrder({ body: formatFormValues(values) })
.then(({ body: { id: orderId } }) => {
navigate({ pathname: URLs.orderView.getUrl(orderId) });
toast({
status: 'success',
title: t('create-order-query.success.title'),
});
})
.catch(({ error: errorMessage }) => {
toast({
status: 'error',
title: t('create-order-query.error.title'),
description: errorMessage,
});
});
};
return ( return (
<LandingThemeProvider>
<Container <Container
w='full' w='full'
maxWidth='container.xl' maxWidth='container.xl'
@ -48,16 +21,12 @@ const Page: FC = () => {
centerContent centerContent
> >
<VStack w='full' h='full' alignItems='stretch' flexGrow={1}> <VStack w='full' h='full' alignItems='stretch' flexGrow={1}>
<Heading textAlign='center' mt={4}> <Heading textAlign='center' mt={4}>{t('title')}</Heading>
{t('title')} <OrderForm />
</Heading>
<OrderForm
onSubmit={onOrderFormSubmit}
loading={createOrderMutation.isLoading}
/>
</VStack> </VStack>
</Container> </Container>
</LandingThemeProvider>
); );
}; };
export default withLandingThemeProvider(Page); export default Page;

View File

@ -0,0 +1,40 @@
import { useEffect, useState } from 'react';
import { LandingService } from '../../api/landing';
import { Order } from '../../models/landing';
import { FetchOrderQueryResponse } from '../../models/api';
export const useFetchOrderDetails = ({
orderId,
}: {
orderId: Order.View['id'];
}) => {
const { fetchOrder } = LandingService();
const [data, setData] = useState<FetchOrderQueryResponse>();
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const fetchData = async () => {
setIsLoading(true);
try {
const data = await fetchOrder(orderId);
setData(data.body);
} catch (error) {
setError(error.message);
} finally {
setIsLoading(false);
}
};
fetchData();
}, []);
return {
isLoading,
data,
error,
};
};

View File

@ -1,32 +1,36 @@
import React, { FC } from 'react'; import React, { FC, useEffect } from 'react';
import { Alert, AlertDescription, AlertIcon, AlertTitle, HStack, Spinner } from '@chakra-ui/react'; import { HStack, Spinner, useToast } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Container, Heading, VStack } from '@chakra-ui/react'; import { Container, Heading, VStack } from '@chakra-ui/react';
import { useParams } from 'react-router-dom'; import { useParams } from 'react-router-dom';
import { import { LandingThemeProvider } from '../../containers';
LandingThemeProvider,
withLandingThemeProvider,
} from '../../containers';
import { OrderDetails } from '../../components/order-view'; import { OrderDetails } from '../../components/order-view';
import { Order } from '../../models/landing';
import { useGetOrderQuery } from '../../api'; import { useFetchOrderDetails } from './helper';
const Page: FC = () => { const Page: FC = () => {
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.order-view', keyPrefix: 'dry-wash.order-view',
}); });
const { orderId } = useParams<Order.Id>(); const { orderId } = useParams();
const {
isLoading, const { isLoading, data, error } = useFetchOrderDetails({ orderId });
isSuccess,
data: { body: order } = {}, const toast = useToast();
isError, useEffect(() => {
error, if (error) {
} = useGetOrderQuery({ toast({
orderId, title: t('error.title'),
description: t('fetch.error'),
status: 'error',
duration: 5000,
isClosable: true,
position: 'bottom-right',
}); });
}
}, [error]);
return ( return (
<LandingThemeProvider> <LandingThemeProvider>
@ -47,37 +51,20 @@ const Page: FC = () => {
<Spinner size='lg' /> <Spinner size='lg' />
</HStack> </HStack>
) : ( ) : (
<> data && (
<>
{isSuccess && (
<OrderDetails <OrderDetails
id={order.id} id={data.id}
status={order.status} orderDate={data.orderDate}
phone={order.phone} status={data.status}
carNumber={order.carNumber} phone={data.phone}
carBody={order.carBody} carNumber={data.carNumber}
carColor={order.carColor} carBody={data.carBody}
location={order.location} carColor={data.carColor}
startWashTime={order.startWashTime} location={data.location}
endWashTime={order.endWashTime} datetimeBegin={data.startWashTime}
created={order.created} datetimeEnd={data.endWashTime}
/> />
)} )
</>
<>
{isError && (
<Alert status='error'>
<AlertIcon />
<AlertTitle>
{t('get-order-query.error.title', {
number: orderId,
})}
</AlertTitle>
<AlertDescription>{error.data?.error}</AlertDescription>
</Alert>
)}
</>
</>
)} )}
</VStack> </VStack>
</Container> </Container>
@ -85,4 +72,4 @@ const Page: FC = () => {
); );
}; };
export default withLandingThemeProvider(Page); export default Page;

View File

@ -18,13 +18,11 @@ router.get('/', (req, res) => {
<legend>Мастера</legend> <legend>Мастера</legend>
${generateRadioInput('masters', 'success')} ${generateRadioInput('masters', 'success')}
${generateRadioInput('masters', 'error')} ${generateRadioInput('masters', 'error')}
${generateRadioInput('masters', 'empty')}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Заказы</legend> <legend>Заказы</legend>
${generateRadioInput('orders', 'success')} ${generateRadioInput('orders', 'success')}
${generateRadioInput('orders', 'error')} ${generateRadioInput('orders', 'error')}
${generateRadioInput('orders', 'empty')}
</fieldset> </fieldset>
<fieldset> <fieldset>
<legend>Лендинг - Детали заказа</legend> <legend>Лендинг - Детали заказа</legend>

View File

@ -23,46 +23,6 @@ router.get('/arm/masters', (req, res) => {
); );
}); });
router.post('/arm/masters', (req, res) => {
res
.status(/error/.test(STUBS.masters) ? 500 : 200)
.send(
/^error$/.test(STUBS.masters)
? commonError
: require(`../json/arm-masters/${STUBS.masters}.json`),
);
});
router.patch('/arm/masters/:id', (req, res) => {
res
.status(/error/.test(STUBS.masters) ? 500 : 200)
.send(
/^error$/.test(STUBS.masters)
? commonError
: require(`../json/arm-masters/${STUBS.masters}.json`),
);
});
router.delete('/arm/masters/:id', (req, res) => {
res
.status(/error/.test(STUBS.masters) ? 500 : 200)
.send(
/^error$/.test(STUBS.masters)
? commonError
: require(`../json/arm-masters/${STUBS.masters}.json`),
);
});
router.patch('/orders/:id', (req, res) => {
res
.status(/error/.test(STUBS.orders) ? 500 : 200)
.send(
/^error$/.test(STUBS.orders)
? commonError
: require(`../json/arm-orders/${STUBS.orders}.json`),
);
});
router.post('/arm/orders', (req, res) => { router.post('/arm/orders', (req, res) => {
res res
.status(/error/.test(STUBS.orders) ? 500 : 200) .status(/error/.test(STUBS.orders) ? 500 : 200)
@ -86,10 +46,6 @@ router.get('/order/:orderId', ({ params }, res) => {
); );
}); });
router.post('/order/create', (req, res) => {
res.status(200).send({ success: true, body: { ok: true } });
});
router.use('/admin', require('./admin')); router.use('/admin', require('./admin'));
module.exports = router; module.exports = router;

View File

@ -1,4 +0,0 @@
{
"success": true,
"body": []
}

View File

@ -2,18 +2,23 @@
"success": true, "success": true,
"body": [ "body": [
{ {
"id": "4545423234", "id": "masters1",
"name": "Иван Иванов", "name": "Иван Иванов",
"schedule": [ {
"id": "order1",
"startWashTime": "2024-11-24T10:30:00.000Z",
"endWashTime": "2024-11-24T16:30:00.000Z"
},
{
"id": "order2",
"startWashTime": "2024-11-24T11:30:00.000Z",
"endWashTime": "2024-11-24T17:30:00.000Z"
}],
"phone": "+7 900 123 45 67" "phone": "+7 900 123 45 67"
}, },
{ {
"name": "Олег Макаров", "id": "masters12",
"phone": "79001234567", "name": "Иван Иванов",
"id": "23423442"
},
{
"id": "345354234",
"name": "Иван Галкин",
"schedule": [ { "schedule": [ {
"id": "order1", "id": "order1",
"startWashTime": "2024-11-24T10:30:00.000Z", "startWashTime": "2024-11-24T10:30:00.000Z",

View File

@ -1,4 +0,0 @@
{
"success": true,
"body": []
}

View File

@ -7,15 +7,9 @@
"startWashTime": "2024-11-24T10:30:00.000Z", "startWashTime": "2024-11-24T10:30:00.000Z",
"endWashTime": "2024-11-24T16:30:00.000Z", "endWashTime": "2024-11-24T16:30:00.000Z",
"orderDate": "2024-11-24T08:41:46.366Z", "orderDate": "2024-11-24T08:41:46.366Z",
"status": "pending", "status": "progress",
"phone": "79001234563", "phone": "79001234563",
"location": "Казань, ул. Баумана, 1", "location": "Казань, ул. Баумана, 1"
"master": {
"name": "Олег Макаров",
"phone": "79001234567",
"id": "23423442"
},
"notes": ""
}, },
{ {
"id": "order2", "id": "order2",
@ -25,9 +19,7 @@
"orderDate": "2024-11-24T07:40:46.366Z", "orderDate": "2024-11-24T07:40:46.366Z",
"status": "progress", "status": "progress",
"phone": "79001234567", "phone": "79001234567",
"location": "Казань, ул. Баумана, 43", "location": "Казань, ул. Баумана, 43"
"master": [],
"notes": ""
} }
] ]
} }

View File

@ -1,17 +1,14 @@
{ {
"success": true, "success": true,
"body": { "body": {
"phone": "+79876543210", "id": "order1",
"carNumber": "А123АА16", "orderDate": "2024-11-24T08:41:46.366Z",
"carBody": 2,
"carColor": "#ffffff",
"startWashTime": "2025-01-19T14:03:00.000Z",
"endWashTime": "2025-01-19T14:03:00.000Z",
"location": "55.793833888711006,49.19037910644527 Республика Татарстан (Татарстан), Казань, жилой район Седьмое Небо",
"status": "progress", "status": "progress",
"notes": "", "carNumber": "A123BC",
"created": "2025-01-19T14:04:02.985Z", "carBody": 1,
"updated": "2025-01-19T14:04:02.987Z", "startWashTime": "2024-11-24T10:30:00.000Z",
"id": "678d06527d78ec30be2679d8" "endWashTime": "2024-11-24T16:30:00.000Z",
"phone": "79001234563",
"location": "55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500"
} }
} }