Compare commits

...

17 Commits

Author SHA1 Message Date
85d96b930c Merge pull request 'feat/create-order-stubs' (#66) from feat/create-order-stubs into main
Reviewed-on: #66
Reviewed-by: Primakov Alexandr Alexandrovich <primakovpro@gmail.com>
2025-01-21 17:32:41 +03:00
RustamRu
adb812280d feat: create order stubs (#65) 2025-01-21 17:29:56 +03:00
RustamRu
3382ae3ada 0.5.0
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-19 11:06:34 +03:00
RustamRu
5ed023866e fix: washing dates format
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-19 11:04:44 +03:00
RustamRu
e73773f359 Merge branch 'main' of ssh://85.143.175.152:222/dry_wash_inc/dry-wash-pl
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-19 10:55:56 +03:00
RustamRu
a9a9b3cadd feat: create order api 2025-01-19 10:55:44 +03:00
06abc15c9a feat: add const for time
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-19 10:50:28 +03:00
6ca7de9467 feat: add start time and end time
Some checks failed
it-academy/dry-wash-pl/pipeline/head There was a failure building this commit
2025-01-19 10:45:40 +03:00
939f107d1c Merge pull request 'feat: add a name and phone change from the Masters (#62)' (#63) from feature/update-master into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #63
Reviewed-by: Primakov Alexandr Alexandrovich <primakovpro@gmail.com>
2025-01-19 10:14:40 +03:00
4f92125e6d feat: add a name and phone change from the Masters (#62)
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
it-academy/dry-wash-pl/pipeline/pr-main This commit looks good
2025-01-18 16:06:27 +03:00
3ea501161c fix: add schedule fix
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-12 11:16:58 +03:00
e8634c396f 0.4.0
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-12 10:15:20 +03:00
edddf6d857 Merge pull request 'feat: add image to car body style select option (#55)' (#61) from feature/graphic-car-body-select into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #61
Reviewed-by: Primakov Alexandr Alexandrovich <primakovpro@gmail.com>
2025-01-12 10:13:19 +03:00
1276b13fec Merge pull request 'feat: add fetch for multi-stub (#59)' (#60) from feature/arm-masters-back into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #60
2025-01-12 10:13:04 +03:00
2aa361e3db Merge pull request 'feat: add region code to car number (#54)' (#58) from feature/car-number-region-code into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #58
Reviewed-by: Primakov Alexandr Alexandrovich <primakovpro@gmail.com>
2025-01-12 10:12:42 +03:00
RustamRu
4cda998bd7 feat: add image to car body style select option (#55)
All checks were successful
it-academy/dry-wash-pl/pipeline/pr-main This commit looks good
it-academy/dry-wash-pl/pipeline/head This commit looks good
2025-01-12 09:05:36 +03:00
RustamRu
3e0e570ff6 feat: add region code to car number (#54)
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
it-academy/dry-wash-pl/pipeline/pr-main This commit looks good
2025-01-11 18:27:12 +03:00
50 changed files with 870 additions and 1404 deletions

View File

@ -39,9 +39,10 @@
"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.error.title": "Error", "dry-wash.order-view.get-order-query.error.title": "Failed to fetch the details of order #{{number}}",
"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",
@ -73,6 +74,10 @@
"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

@ -24,6 +24,10 @@
"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": "Введите ФИО",
@ -74,9 +78,10 @@
"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.error.title": "Ошибка", "dry-wash.order-view.get-order-query.error.title": "Не удалось загрузить детали заказа №{{number}}",
"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": "Автомобиль",

1306
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.3.0", "version": "0.5.0",
"description": "<a id=\"readme-top\"></a>", "description": "<a id=\"readme-top\"></a>",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"scripts": { "scripts": {
@ -19,7 +19,7 @@
"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.4.2", "@chakra-ui/react": "^2.10.5",
"@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",

View File

@ -1,4 +1,5 @@
import { getConfigValue } from '@brojs/cli'; import { getConfigValue } from '@brojs/cli';
import dayjs from 'dayjs';
enum ArmEndpoints { enum ArmEndpoints {
ORDERS = '/arm/orders', ORDERS = '/arm/orders',
@ -9,12 +10,15 @@ 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({ date }), body: JSON.stringify({ startDate, endDate }),
}); });
if (!response.ok) { if (!response.ok) {
@ -68,7 +72,33 @@ const armService = () => {
return await response.json(); return await response.json();
}; };
return { fetchOrders, fetchMasters, addMaster, deleteMaster }; 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 };
}; };
export { armService, ArmEndpoints }; export { armService, ArmEndpoints };

1
src/api/index.ts Normal file
View File

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

View File

@ -1,25 +0,0 @@
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 };

107
src/api/landing.tsx Normal file
View File

@ -0,0 +1,107 @@
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 };

22
src/api/types.ts Normal file
View File

@ -0,0 +1,22 @@
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>;

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

View File

@ -0,0 +1,10 @@
export { default as CoupeImg } from './coupe.webp';
export { default as CrossoverImg } from './crossover.webp';
export { default as HatchbackImg } from './hatchback.webp';
export { default as LiftbackImg } from './liftback.webp';
export { default as MinivanImg } from './minivan.webp';
export { default as PickupImg } from './pickup.webp';
export { default as SedanImg } from './sedan.webp';
export { default as SportsCarImg } from './sports-car.webp';
export { default as StationWagonImg } from './station-wagon.webp';
export { default as SuvImg } from './suv.webp';

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 KiB

View File

@ -1 +1,2 @@
export * from './car-body-type';
export { default as DemoVideoPosterImg } from './demo-video-poster.webp'; export { default as DemoVideoPosterImg } from './demo-video-poster.webp';

View File

@ -0,0 +1,119 @@
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

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

View File

@ -1,23 +1,94 @@
import React, { forwardRef } from 'react'; import React, { forwardRef, useState } from 'react';
import { Select, SelectProps } from '@chakra-ui/react'; import {
Input,
Image,
InputProps,
Box,
Popover,
PopoverAnchor,
PopoverContent,
PopoverBody,
List,
ListItem,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { carBodySelectOptions } from './helper'; import { carBodySelectOptions } from './helper';
import { CarBodySelectOption } from './types';
export const CarBodySelect = forwardRef<HTMLSelectElement, SelectProps>( 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>>(initialOption);
const handleOptionClick = (option: CarBodySelectOption) => {
setSelected(option);
// eslint-disable-next-line @typescript-eslint/ban-ts-comment
// @ts-ignore
props.onChange(option.value);
};
const [isDropdownOpen, setIsDropdownOpen] = useState<boolean>(false);
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.order-create.car-body-select', keyPrefix: 'dry-wash.order-create.car-body-select',
}); });
return ( return (
<Select ref={ref} placeholder={t('placeholder')} {...props}> <Box width='100%'>
{carBodySelectOptions.map(({ value, labelTKey }, i) => ( <Popover
<option key={i} value={value}> isOpen={isDropdownOpen}
{t(`options.${labelTKey}`)} autoFocus={false}
</option> placement='bottom-start'
))} matchWidth
</Select> >
<PopoverAnchor>
<Input
{...props}
ref={ref}
value={
selected?.labelTKey
? t(`options.${selected.labelTKey}`)
: undefined
}
readOnly
onClick={() => setIsDropdownOpen(true)}
onBlur={() => setIsDropdownOpen(false)}
placeholder={t('placeholder')}
/>
</PopoverAnchor>
<PopoverContent width='100%' maxWidth='100%'>
<PopoverBody border='1px' borderColor='gray.300' p={0}>
<List
display='grid'
gridTemplateColumns='repeat(auto-fit, minmax(150px, 1fr))'
>
{carBodySelectOptions.map((option) => (
<ListItem
key={option.value}
display='flex'
flexDirection='column'
justifyContent='flex-end'
alignItems='center'
p={2}
cursor='pointer'
_hover={{
bgColor: 'primary.50',
}}
_active={{
bgColor: 'primary.100',
}}
onClick={() => handleOptionClick(option)}
>
<Image src={option.img} />
{t(`options.${option.labelTKey}`)}
</ListItem>
))}
</List>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
); );
}, },
); );

View File

@ -1,3 +1,15 @@
import {
CoupeImg,
CrossoverImg,
HatchbackImg,
LiftbackImg,
MinivanImg,
PickupImg,
SedanImg,
SportsCarImg,
StationWagonImg,
SuvImg
} from "../../../../assets/images";
import { Car } from "../../../../models/landing"; import { Car } from "../../../../models/landing";
import { CarBodySelectOption } from "./types"; import { CarBodySelectOption } from "./types";
@ -5,43 +17,53 @@ import { CarBodySelectOption } from "./types";
export const carBodySelectOptions: CarBodySelectOption[] = [ export const carBodySelectOptions: CarBodySelectOption[] = [
{ {
value: Car.BodyStyle.SEDAN, value: Car.BodyStyle.SEDAN,
labelTKey: 'sedan' labelTKey: 'sedan',
img: SedanImg
}, },
{ {
value: Car.BodyStyle.HATCHBACK, value: Car.BodyStyle.HATCHBACK,
labelTKey: 'hatchback' labelTKey: 'hatchback',
img: HatchbackImg
}, },
{ {
value: Car.BodyStyle.CROSSOVER, value: Car.BodyStyle.CROSSOVER,
labelTKey: 'crossover' labelTKey: 'crossover',
img: CrossoverImg
}, },
{ {
value: Car.BodyStyle.SUV, value: Car.BodyStyle.SUV,
labelTKey: 'suv' labelTKey: 'suv',
img: SuvImg
}, },
{ {
value: Car.BodyStyle.STATION_WAGON, value: Car.BodyStyle.STATION_WAGON,
labelTKey: 'station-wagon' labelTKey: 'station-wagon',
img: StationWagonImg
}, },
{ {
value: Car.BodyStyle.COUPE, value: Car.BodyStyle.COUPE,
labelTKey: 'coupe' labelTKey: 'coupe',
img: CoupeImg
}, },
{ {
value: Car.BodyStyle.MINIVAN, value: Car.BodyStyle.MINIVAN,
labelTKey: 'minivan' labelTKey: 'minivan',
img: MinivanImg
}, },
{ {
value: Car.BodyStyle.PICKUP, value: Car.BodyStyle.PICKUP,
labelTKey: 'pickup' labelTKey: 'pickup',
img: PickupImg
}, },
{ {
value: Car.BodyStyle.LIFTBACK, value: Car.BodyStyle.LIFTBACK,
labelTKey: 'liftback' labelTKey: 'liftback',
img: LiftbackImg
}, },
{ {
value: Car.BodyStyle.SPORTS_CAR, value: Car.BodyStyle.SPORTS_CAR,
labelTKey: 'sports-car' labelTKey: 'sports-car',
img: SportsCarImg
}, },
{ {
value: Car.BodyStyle.OTHER, value: Car.BodyStyle.OTHER,

View File

@ -14,4 +14,5 @@ export type CarBodySelectOption = {
'liftback' | 'liftback' |
'sports-car' | 'sports-car' |
'other'; 'other';
img?: string;
}; };

View File

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

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}|$)$`, 'gi'); 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 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) => [p1, p2, p3].join(' ')).toUpperCase(); return cleanedValue.replace(validCarNumberInputRe, (_, p1, p2, p3, p4) => [p1, p2, p3, p4].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}$`, 'i'); const validCarNumberRe = new RegExp(`^[${VALID_LETTER}][0-9]{3}[${VALID_LETTER}]{2}[0-9]{2,3}$`, 'i');
export const isValidCarNumber = (value: string) => { export const isValidCarNumber = (value: string) => {
const cleanedValue = cleanValue(value); const cleanedValue = cleanValue(value);

View File

@ -1,7 +1,4 @@
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";
@ -29,39 +26,4 @@ export const useGetValidationRules = () => {
validate: (value: string) => isValidCarNumber(value) || t('car-number-field.invalid') validate: (value: string) => isValidCarNumber(value) || t('car-number-field.invalid')
}, },
} 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 +1,2 @@
export type { OrderFormValues, OrderFormProps } from './types';
export { OrderForm } from './order-form'; export { OrderForm } from './order-form';

View File

@ -1,4 +1,4 @@
import React, { FC } from 'react'; import React 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,14 +7,19 @@ 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 { OrderFormValues } from './types'; import { OrderFormProps, OrderFormValues } from './types';
import { PhoneInput } from './phone'; import { PhoneInput } from './phone';
import { SubmitButton } from './submit'; import { SubmitButton } from './submit';
import { defaultValues, onSubmit, useGetValidationRules } from './helper'; import { defaultValues, useGetValidationRules } from './helper';
import { DateTimeInput } from './date-time'; import { DateTimeInput } from './date-time';
import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location'; import {
LocationInput,
MapComponent,
StringLocation,
YMapsProvider,
} from './location';
export const OrderForm: FC = () => { export const OrderForm = ({ onSubmit, loading }: OrderFormProps) => {
const { const {
handleSubmit, handleSubmit,
control, control,
@ -123,7 +128,7 @@ export const OrderForm: FC = () => {
}} }}
/> />
</YMapsProvider> </YMapsProvider>
<SubmitButton isLoading={isSubmitting} mt={4} /> <SubmitButton isLoading={isSubmitting || loading} mt={4} />
</VStack> </VStack>
</Box> </Box>
); );

View File

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

View File

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

View File

@ -1,14 +1,31 @@
import React, { FC, PropsWithChildren } from 'react'; import React, { ComponentType, 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}> <ChakraProvider theme={landingTheme} toastOptions={toastOptions}>
<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 } from './LandingThemeProvider'; export { LandingThemeProvider, withLandingThemeProvider } from './LandingThemeProvider';

View File

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

View File

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

View File

@ -1,14 +0,0 @@
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;
};

18
src/models/api/order.ts Normal file
View File

@ -0,0 +1,18 @@
/* 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
};
};

1
src/models/common.ts Normal file
View File

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

View File

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

View File

@ -1,3 +1,5 @@
import { IsoDate } from "../common";
import { Car, Customer, Washing } from "."; import { Car, Customer, Washing } from ".";
export type Id = string; export type Id = string;
@ -25,14 +27,16 @@ 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;
datetimeBegin: Washing.AvailableBeginDateTime; startWashTime: Washing.AvailableBeginDateTime;
datetimeEnd: Washing.AvailableEndDateTime; endWashTime: Washing.AvailableEndDateTime;
status: Status,
notes: string;
created: IsoDate;
updated: IsoDate;
id: Id;
}; };

View File

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

View File

@ -0,0 +1,29 @@
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,32 +1,63 @@
import React, { FC } from 'react'; import React, { FC } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Container, Heading, VStack } from '@chakra-ui/react'; import { Container, Heading, useToast, VStack } from '@chakra-ui/react';
import { useNavigate } from 'react-router-dom';
import { LandingThemeProvider } from '../../containers'; import { withLandingThemeProvider } from '../../containers';
import { OrderForm } from '../../components/order-form'; import { OrderForm, OrderFormProps } 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' minH='100vh'
minH='100vh' padding={0}
padding={0} bg='white'
bg='white' 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>
</VStack> <OrderForm
</Container> onSubmit={onOrderFormSubmit}
</LandingThemeProvider> loading={createOrderMutation.isLoading}
/>
</VStack>
</Container>
); );
}; };
export default Page; export default withLandingThemeProvider(Page);

View File

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

View File

@ -76,6 +76,12 @@ 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

@ -2,22 +2,12 @@
"success": true, "success": true,
"body": [ "body": [
{ {
"id": "masters1", "id": "4545423234",
"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"
}, },
{ {
"id": "masters12", "id": "345354234",
"name": "Иван Иванов", "name": "Иван Иванов",
"schedule": [ { "schedule": [ {
"id": "order1", "id": "order1",

View File

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