Compare commits

...

13 Commits

Author SHA1 Message Date
885ad16782 feat: add keycloak-js for arm
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
2024-12-22 12:36:58 +03:00
707a77d7ad Merge pull request 'feat: use react-yandex-maps for location input (#8)' (#50) from feature/order-form into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #50
2024-12-22 11:21:01 +03:00
RustamRu
b8606ac05f feat: use react-yandex-maps for location input (#8)
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
2024-12-22 08:52:07 +03:00
6218b6f5d8 Merge pull request 'feature/change-days' (#49) from feature/change-days into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #49
2024-12-15 17:47:34 +03:00
8cc8391a09 feat: add a component for changing days (#48)
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
2024-12-14 22:27:07 +03:00
e2c65fd39c fix: change the paths for localization (#47)
Some checks failed
it-academy/dry-wash-pl/pipeline/head There was a failure building this commit
2024-12-14 18:27:21 +03:00
9f530204fa 0.2.0
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2024-12-08 11:57:46 +03:00
fb3ca156fb Merge pull request 'feature/order-form' (#46) from feature/order-form into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #46
2024-12-08 11:31:17 +03:00
48d076a829 Merge pull request 'feature/successJson' (#45) from feature/successJson into main
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
Reviewed-on: #45
2024-12-08 11:18:35 +03:00
e4b9aefe57 fix: add spaces
All checks were successful
it-academy/dry-wash-pl/pipeline/pr-main This commit looks good
2024-12-08 11:00:43 +03:00
0b2dae79ac feat: add stub fetch admin (#44)
Some checks failed
it-academy/dry-wash-pl/pipeline/pr-main There was a failure building this commit
it-academy/dry-wash-pl/pipeline/head There was a failure building this commit
2024-12-07 23:41:26 +03:00
RustamRu
7c157574c8 feat: extract admin (#43)
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2024-12-01 12:17:04 +03:00
5ef0d5953d feat: add stub admin (#43)
All checks were successful
it-academy/dry-wash-pl/pipeline/head This commit looks good
2024-12-01 11:53:20 +03:00
29 changed files with 805 additions and 58 deletions

View File

@@ -24,6 +24,6 @@ module.exports = {
}, },
}, },
config: { config: {
'dry-wash-pl.api': '/api', 'dry-wash.api': '/api',
}, },
}; };

View File

@@ -24,6 +24,7 @@
"dry-wash.order-create.form.car-body-field.label": "Car body type", "dry-wash.order-create.form.car-body-field.label": "Car body type",
"dry-wash.order-create.form.washing-datetime-field.label": "What time is the car available?", "dry-wash.order-create.form.washing-datetime-field.label": "What time is the car available?",
"dry-wash.order-create.form.washing-location-field.label": "Where is the car located?", "dry-wash.order-create.form.washing-location-field.label": "Where is the car located?",
"dry-wash.order-create.form.washing-location-field.placeholder": "Enter the address or select on the map",
"dry-wash.order-create.form.washing-location-field.help": "For example, 55.754364, 48.743295 Universitetskaya Street, 1, Innopolis, Verkhneuslonsky district, Republic of Tatarstan (Tatarstan), 420500", "dry-wash.order-create.form.washing-location-field.help": "For example, 55.754364, 48.743295 Universitetskaya Street, 1, Innopolis, Verkhneuslonsky district, Republic of Tatarstan (Tatarstan), 420500",
"dry-wash.order-create.car-body-select.placeholder": "Not specified", "dry-wash.order-create.car-body-select.placeholder": "Not specified",
"dry-wash.order-create.car-body-select.options.sedan": "Sedan", "dry-wash.order-create.car-body-select.options.sedan": "Sedan",
@@ -40,6 +41,8 @@
"dry-wash.order-create.form.submit-button.label": "Submit", "dry-wash.order-create.form.submit-button.label": "Submit",
"dry-wash.arm.master.add": "Add", "dry-wash.arm.master.add": "Add",
"dry-wash.arm.order.title": "Orders", "dry-wash.arm.order.title": "Orders",
"dry-wash.arm.order.table.empty": "Table empty",
"dry-wash.arm.order.error.title": "Error loading data",
"dry-wash.arm.order.status.progress": "In Progress", "dry-wash.arm.order.status.progress": "In Progress",
"dry-wash.arm.order.status.complete": "Completed", "dry-wash.arm.order.status.complete": "Completed",
"dry-wash.arm.order.status.pending": "Pending", "dry-wash.arm.order.status.pending": "Pending",
@@ -54,6 +57,8 @@
"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",
"dry-wash.arm.master.table.header.name": "Name", "dry-wash.arm.master.table.header.name": "Name",
"dry-wash.arm.master.table.empty": "Table empty",
"dry-wash.arm.master.error.title": "Error loading data",
"dry-wash.arm.master.table.header.currentJob": "Current Job", "dry-wash.arm.master.table.header.currentJob": "Current Job",
"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",
@@ -73,5 +78,6 @@
"dry-wash.notFound.button.back": "Back to Home", "dry-wash.notFound.button.back": "Back to Home",
"dry-wash.errorBoundary.title": "Something went wrong", "dry-wash.errorBoundary.title": "Something went wrong",
"dry-wash.errorBoundary.description": "We are already working on fixing the issue", "dry-wash.errorBoundary.description": "We are already working on fixing the issue",
"dry-wash.errorBoundary.button.reload": "Reload Page" "dry-wash.errorBoundary.button.reload": "Reload Page",
"dry-wash.washTime.timeSlot": "{{start}} - {{end}}"
} }

View File

@@ -13,7 +13,11 @@
"dry-wash.arm.order.table.header.status": "Статус", "dry-wash.arm.order.table.header.status": "Статус",
"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.error.title": "Ошибка при загрузке данных",
"dry-wash.arm.master.title": "Мастера", "dry-wash.arm.master.title": "Мастера",
"dry-wash.arm.master.table.empty": "Список пуст",
"dry-wash.arm.master.error.title": "Ошибка при загрузке данных",
"dry-wash.arm.master.table.header.name": "Имя", "dry-wash.arm.master.table.header.name": "Имя",
"dry-wash.arm.master.table.header.currentJob": "Актуальная занятость", "dry-wash.arm.master.table.header.currentJob": "Актуальная занятость",
"dry-wash.arm.master.table.header.phone": "Телефон", "dry-wash.arm.master.table.header.phone": "Телефон",
@@ -54,6 +58,7 @@
"dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля", "dry-wash.order-create.form.car-body-field.label": "Тип кузова автомобиля",
"dry-wash.order-create.form.washing-datetime-field.label": "В какое время автомобиль доступен?", "dry-wash.order-create.form.washing-datetime-field.label": "В какое время автомобиль доступен?",
"dry-wash.order-create.form.washing-location-field.label": "Где находится автомобиль?", "dry-wash.order-create.form.washing-location-field.label": "Где находится автомобиль?",
"dry-wash.order-create.form.washing-location-field.placeholder": "Введите адрес или выберите на карте",
"dry-wash.order-create.form.washing-location-field.help": "Например, 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500", "dry-wash.order-create.form.washing-location-field.help": "Например, 55.754364, 48.743295 Университетская улица, 1, Иннополис, Верхнеуслонский район, Республика Татарстан (Татарстан), 420500",
"dry-wash.order-create.car-body-select.placeholder": "Не указан", "dry-wash.order-create.car-body-select.placeholder": "Не указан",
"dry-wash.order-create.car-body-select.options.sedan": "Седан", "dry-wash.order-create.car-body-select.options.sedan": "Седан",

69
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{ {
"name": "dry-wash", "name": "dry-wash",
"version": "0.1.1", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "dry-wash", "name": "dry-wash",
"version": "0.1.1", "version": "0.2.0",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@brojs/cli": "^1.6.3", "@brojs/cli": "^1.6.3",
@@ -16,11 +16,13 @@
"@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",
"@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",
"framer-motion": "^6.2.8", "framer-motion": "^6.2.8",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"keycloak-js": "^23.0.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.2", "react-hook-form": "^7.53.2",
@@ -3594,6 +3596,21 @@
"node": ">= 8" "node": ">= 8"
} }
}, },
"node_modules/@pbe/react-yandex-maps": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/@pbe/react-yandex-maps/-/react-yandex-maps-1.2.5.tgz",
"integrity": "sha512-cBojin5e1fPx9XVCAqHQJsCnHGMeBNsP0TrNfpWCrPFfxb30ye+JgcGr2mn767Gbr1d+RufBLRiUcX2kaiAwjQ==",
"license": "MIT",
"dependencies": {
"@types/yandex-maps": "2.1.29"
},
"engines": {
"node": ">=16"
},
"peerDependencies": {
"react": "^16.x || ^17.x || ^18.x"
}
},
"node_modules/@pkgjs/parseargs": { "node_modules/@pkgjs/parseargs": {
"version": "0.11.0", "version": "0.11.0",
"resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz",
@@ -3774,6 +3791,12 @@
"@types/react": "*" "@types/react": "*"
} }
}, },
"node_modules/@types/yandex-maps": {
"version": "2.1.29",
"resolved": "https://registry.npmjs.org/@types/yandex-maps/-/yandex-maps-2.1.29.tgz",
"integrity": "sha512-nuibRWj3RU/9KXlCzTrRtDE+n6V9l7NbT9JakicqZ5OXIdwyb6blvV2Uwn6lB58WYm3DSUDP2I2AWlnWMc8z2w==",
"license": "MIT"
},
"node_modules/@typescript-eslint/eslint-plugin": { "node_modules/@typescript-eslint/eslint-plugin": {
"version": "8.12.2", "version": "8.12.2",
"resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.12.2.tgz",
@@ -4707,6 +4730,25 @@
"resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz",
"integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==" "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="
}, },
"node_modules/base64-js": {
"version": "1.5.1",
"resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
"integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/feross"
},
{
"type": "patreon",
"url": "https://www.patreon.com/feross"
},
{
"type": "consulting",
"url": "https://feross.org/support"
}
]
},
"node_modules/binary-extensions": { "node_modules/binary-extensions": {
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz", "resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -8136,6 +8178,11 @@
"node": ">= 10.13.0" "node": ">= 10.13.0"
} }
}, },
"node_modules/js-sha256": {
"version": "0.10.1",
"resolved": "https://registry.npmjs.org/js-sha256/-/js-sha256-0.10.1.tgz",
"integrity": "sha512-5obBtsz9301ULlsgggLg542s/jqtddfOpV5KJc4hajc9JV8GeY2gZHSVpYBn4nWqAUTJ9v+xwtbJ1mIBgIH5Vw=="
},
"node_modules/js-tokens": { "node_modules/js-tokens": {
"version": "4.0.0", "version": "4.0.0",
"resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz",
@@ -8229,6 +8276,24 @@
"node": ">=4.0" "node": ">=4.0"
} }
}, },
"node_modules/jwt-decode": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/jwt-decode/-/jwt-decode-4.0.0.tgz",
"integrity": "sha512-+KJGIyHgkGuIq3IEBNftfhW/LfWhXUIY6OmyVWjliu5KH1y0fw7VQ8YndE2O4qZdMSd9SqbnC8GOcZEy0Om7sA==",
"engines": {
"node": ">=18"
}
},
"node_modules/keycloak-js": {
"version": "23.0.7",
"resolved": "https://registry.npmjs.org/keycloak-js/-/keycloak-js-23.0.7.tgz",
"integrity": "sha512-OmszsKzBhhm5yP4W1q/tMd+nNnKpOAdeVYcoGhphlv8Fj1bNk4wRTYzp7pn5BkvueLz7fhvKHz7uOc33524YrA==",
"dependencies": {
"base64-js": "^1.5.1",
"js-sha256": "^0.10.1",
"jwt-decode": "^4.0.0"
}
},
"node_modules/keygrip": { "node_modules/keygrip": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz", "resolved": "https://registry.npmjs.org/keygrip/-/keygrip-1.1.0.tgz",

View File

@@ -1,6 +1,6 @@
{ {
"name": "dry-wash", "name": "dry-wash",
"version": "0.1.1", "version": "0.2.0",
"description": "<a id=\"readme-top\"></a>", "description": "<a id=\"readme-top\"></a>",
"main": "./src/index.tsx", "main": "./src/index.tsx",
"scripts": { "scripts": {
@@ -24,11 +24,13 @@
"@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",
"@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",
"framer-motion": "^6.2.8", "framer-motion": "^6.2.8",
"i18next": "^23.16.4", "i18next": "^23.16.4",
"keycloak-js": "^23.0.7",
"react": "^18.3.1", "react": "^18.3.1",
"react-dom": "^18.3.1", "react-dom": "^18.3.1",
"react-hook-form": "^7.53.2", "react-hook-form": "^7.53.2",

40
src/api/arm.ts Normal file
View File

@@ -0,0 +1,40 @@
import { getConfigValue } from '@brojs/cli';
enum ArmEndpoints {
ORDERS = '/arm/orders',
MASTERS = '/arm/masters',
}
const armService = () => {
const endpoint = getConfigValue('dry-wash.api');
const fetchOrders = async ({ date }: { date: Date }) => {
const response = await fetch(`${endpoint}${ArmEndpoints.ORDERS}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ date }),
});
if (!response.ok) {
throw new Error(`Failed to fetch orders: ${response.status}`);
}
return await response.json();
};
const fetchMasters = async () => {
const response = await fetch(`${endpoint}${ArmEndpoints.MASTERS}`);
if (!response.ok) {
throw new Error(`Failed to fetch masters: ${response.status}`);
}
return await response.json();
};
return { fetchOrders, fetchMasters };
};
export { armService, ArmEndpoints };

View File

@@ -0,0 +1,31 @@
import React from 'react';
import { Box, Button, Text } from '@chakra-ui/react';
import { ArrowBackIcon, ArrowForwardIcon } from '@chakra-ui/icons';
interface DateNavigatorProps {
currentDate: Date;
onPreviousDate: () => void;
onNextDate: () => void;
}
const DateNavigator = ({
currentDate,
onPreviousDate,
onNextDate,
}: DateNavigatorProps) => {
return (
<Box display='flex' alignItems='center' justifyContent='flex-start' mb='5'>
<Button onClick={onPreviousDate}>
<ArrowBackIcon />
</Button>
<Text mx='4' fontSize='lg' fontWeight='bold'>
{currentDate.toLocaleDateString()}
</Text>
<Button onClick={onNextDate}>
<ArrowForwardIcon />
</Button>
</Box>
);
};
export default DateNavigator;

View File

@@ -0,0 +1 @@
export { default } from './DateNavigator';

View File

@@ -4,6 +4,19 @@ import { Badge, Link, Stack, Td, Tr } from '@chakra-ui/react';
import MasterActionsMenu from '../MasterActionsMenu'; import MasterActionsMenu from '../MasterActionsMenu';
import { getTimeSlot } from '../../lib/date-helpers'; import { getTimeSlot } from '../../lib/date-helpers';
export interface Schedule {
id: string;
startWashTime: string;
endWashTime: string;
}
export type MasterProps = {
id: string;
name: string;
schedule: Schedule[];
phone: string;
};
const MasterItem = ({ name, schedule, phone }) => { const MasterItem = ({ name, schedule, phone }) => {
return ( return (
<Tr> <Tr>

View File

@@ -1,4 +1,4 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { import {
Box, Box,
Heading, Heading,
@@ -10,12 +10,17 @@ import {
Button, Button,
useDisclosure, useDisclosure,
Flex, Flex,
useToast,
Td,
Text,
Spinner,
} from '@chakra-ui/react'; } from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import MasterItem from '../MasterItem'; import MasterItem from '../MasterItem';
import MasterDrawer from '../MasterDrawer'; import MasterDrawer from '../MasterDrawer';
import data from '../../../stubs/json/arm-masters/success.json'; import { armService } from '../../api/arm';
import { MasterProps } from '../MasterItem/MasterItem';
const TABLE_HEADERS = [ const TABLE_HEADERS = [
'name' as const, 'name' as const,
@@ -26,11 +31,41 @@ const TABLE_HEADERS = [
const Masters = () => { const Masters = () => {
const { isOpen, onOpen, onClose } = useDisclosure(); const { isOpen, onOpen, onClose } = useDisclosure();
const toast = useToast();
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.master', keyPrefix: 'dry-wash.arm.master',
}); });
const [masters, setMasters] = useState<MasterProps[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const { fetchMasters } = armService();
useEffect(() => {
const loadMasters = async () => {
setLoading(true);
try {
const data = await fetchMasters();
setMasters(data.body);
} catch (err) {
setError(err.message);
toast({
title: t('error.title'),
status: 'error',
duration: 5000,
isClosable: true,
position: 'bottom-right',
});
} finally {
setLoading(false);
}
};
loadMasters();
}, [toast, t]);
return ( return (
<Box p='8'> <Box p='8'>
<Flex justifyContent='space-between' alignItems='center' mb='5'> <Flex justifyContent='space-between' alignItems='center' mb='5'>
@@ -48,9 +83,25 @@ const Masters = () => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{data.body.map((master, index) => ( {loading && (
<MasterItem key={index} {...master} /> <Tr>
))} <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'>
<Spinner size='lg' />
</Td>
</Tr>
)}
{!loading && masters.length === 0 && !error && (
<Tr>
<Td colSpan={TABLE_HEADERS.length}>
<Text>{t('table.empty')}</Text>
</Td>
</Tr>
)}
{!loading &&
!error &&
masters.map((master, index) => (
<MasterItem key={index} {...master} />
))}
</Tbody> </Tbody>
</Table> </Table>
<MasterDrawer isOpen={isOpen} onClose={onClose} /> <MasterDrawer isOpen={isOpen} onClose={onClose} />

View File

@@ -1,30 +1,90 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { Box, Heading, Table, Thead, Tbody, Tr, Th } from '@chakra-ui/react'; import {
Box,
Heading,
Table,
Thead,
Tbody,
Tr,
Th,
Spinner,
Text,
Td,
useToast,
} from '@chakra-ui/react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import dayjs from 'dayjs';
import OrderItem from '../OrderItem'; import OrderItem from '../OrderItem';
import { OrderProps } from '../OrderItem/OrderItem'; import { OrderProps } from '../OrderItem/OrderItem';
import data from '../../../stubs/json/arm-orders/success.json'; import { armService } from '../../api/arm';
import DateNavigator from '../DateNavigator';
const TABLE_HEADERS = [
'carNumber' as const,
'washingTime' as const,
'orderDate' as const,
'status' as const,
'telephone' as const,
'location' as const,
];
const Orders = () => { const Orders = () => {
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.arm.order', keyPrefix: 'dry-wash.arm.order',
}); });
const TABLE_HEADERS = [ const { fetchOrders } = armService();
'carNumber' as const,
'washingTime' as const, const toast = useToast();
'orderDate' as const,
'status' as const, const [orders, setOrders] = useState<OrderProps[]>([]);
'telephone' as const, const [loading, setLoading] = useState(false);
'location' as const, const [error, setError] = useState<string | null>(null);
]; const [currentDate, setCurrentDate] = useState(new Date());
useEffect(() => {
const loadOrders = async () => {
setLoading(true);
try {
const data = await fetchOrders({ date: currentDate });
setOrders(data.body);
} catch (err) {
setError(err.message);
toast({
title: t('error.title'),
status: 'error',
duration: 5000,
isClosable: true,
position: 'bottom-right',
});
} finally {
setLoading(false);
}
};
loadOrders();
}, [toast, t, currentDate]);
return ( return (
<Box p='8'> <Box p='8'>
<Heading size='lg' mb='5'> <Heading size='lg' mb='5'>
{t('title')} {t('title')}
</Heading> </Heading>
<DateNavigator
currentDate={currentDate}
onPreviousDate={() =>
setCurrentDate((prevDate) =>
dayjs(prevDate).subtract(1, 'day').toDate(),
)
}
onNextDate={() =>
setCurrentDate((prevDate) => dayjs(prevDate).add(1, 'day').toDate())
}
/>
<Table variant='simple' colorScheme='blackAlpha'> <Table variant='simple' colorScheme='blackAlpha'>
<Thead> <Thead>
<Tr> <Tr>
@@ -34,13 +94,29 @@ const Orders = () => {
</Tr> </Tr>
</Thead> </Thead>
<Tbody> <Tbody>
{data.body.map((order, index) => ( {loading && (
<OrderItem <Tr>
key={index} <Td colSpan={TABLE_HEADERS.length} textAlign='center' py='8'>
{...order} <Spinner size='lg' />
status={order.status as OrderProps['status']} </Td>
/> </Tr>
))} )}
{!loading && orders.length === 0 && !error && (
<Tr>
<Td colSpan={TABLE_HEADERS.length}>
<Text>{t('table.empty')}</Text>
</Td>
</Tr>
)}
{!loading &&
!error &&
orders.map((order, index) => (
<OrderItem
key={index}
{...order}
status={order.status as OrderProps['status']}
/>
))}
</Tbody> </Tbody>
</Table> </Table>
</Box> </Box>

View File

@@ -0,0 +1,15 @@
import { Location, StringCoordinates, StringLocation } from "./types";
export const splitLocation = (stringifiedLocation: StringLocation) => {
return stringifiedLocation.replace(' ', '~').split('~');
};
export const stringifyLocation = (location: Location): StringLocation => {
const { coordinates, address } = location;
const [latitude, longitude] = coordinates;
const coordinatesString = `${latitude},${longitude}` satisfies StringCoordinates;
return `${coordinatesString} ${address}`;
};
const locationRe = new RegExp(/^[-0-9]+.[0-9]+,[-0-9]+.[0-9]+ .*/);
export const isValidLocation = (value: string): value is StringLocation => locationRe.test(value);

View File

@@ -0,0 +1,4 @@
export type { StringLocation } from './types';
export * from './location-input';
export * from './map';
export * from './ymaps';

View File

@@ -0,0 +1,40 @@
import { YMapsApi } from "@pbe/react-yandex-maps/typings/util/typing";
import { Address, Location, StringLocation } from "../types";
import { splitLocation, stringifyLocation } from "../helper";
export const formatLocation = (location: Location | undefined): StringLocation | '' => {
if (!location) {
return '';
}
return stringifyLocation(location);
};
const coordinatesRe = new RegExp(/^[-0-9]+.[0-9]+,[-0-9]+.[0-9]+/);
export const extractAddress = (value: string) => value.replace(coordinatesRe, '').trim();
export const getLocationByAddress = async (ymaps: YMapsApi, address: Address): Promise<Location> => {
try {
const result = await ymaps.geocode(address);
const firstGeoObject = result.geoObjects.get(
0,
) as unknown as ymaps.IGeoObject<ymaps.IBasePointGeometry>;
const [latitude, longitude] = firstGeoObject.geometry.getCoordinates();
return { coordinates: [latitude, longitude], address };
} catch (error) {
console.error(error);
}
};
export const isRealLocation = async (ymaps: YMapsApi, stringifiedLocation: StringLocation) => {
const [coordinates, address] = splitLocation(stringifiedLocation);
try {
const result = await ymaps.geocode(coordinates);
const firstGeoObject = result.geoObjects.get(0) as ymaps.GeocodeResult;
const addressByCoordinates = firstGeoObject.getAddressLine();
return address === addressByCoordinates;
} catch (error) {
console.error(error);
}
};

View File

@@ -0,0 +1 @@
export { LocationInput } from './location-input';

View File

@@ -0,0 +1,144 @@
import React, { forwardRef, memo, useEffect, useState } from 'react';
import {
Input,
Box,
InputProps,
PopoverAnchor,
Popover,
PopoverContent,
PopoverBody,
List,
ListItem,
} from '@chakra-ui/react';
import { withYMaps } from '@pbe/react-yandex-maps';
import { useTranslation } from 'react-i18next';
import { Suggestion } from '../types';
import { isValidLocation } from '../helper';
import {
formatLocation,
getLocationByAddress,
isRealLocation,
extractAddress,
} from './helper';
import { LocationInputProps } from './types';
export const LocationInput = memo(
withYMaps(
forwardRef<HTMLInputElement, LocationInputProps>(function LocationInput(
{ ymaps, value, onChange, ...props },
ref,
) {
const [inputValue, setInputValue] = useState<string>('');
useEffect(() => {
setInputValue(value);
}, [value]);
const [suggestions, setSuggestions] = useState<Suggestion[]>([]);
const [isSuggestionsPanelOpen, setIsSuggestionsPanelOpen] =
useState<boolean>(false);
const onInputChange: InputProps['onChange'] = async (e) => {
const newInputValue = e.target.value;
setInputValue(newInputValue);
if (newInputValue.trim().length > 3) {
try {
const address = extractAddress(newInputValue);
const results = await ymaps.suggest(address);
setSuggestions(results);
} catch (error) {
console.error(error);
}
} else {
setSuggestions([]);
}
setIsSuggestionsPanelOpen(suggestions.length > 1);
};
const onFocus: InputProps['onFocus'] = () => {
setIsSuggestionsPanelOpen(suggestions.length > 1);
};
const onBlur: InputProps['onBlur'] = async (e) => {
const inputValue = e.target.value;
if (
isValidLocation(inputValue) &&
(await isRealLocation(ymaps, inputValue))
) {
onChange(inputValue);
} else {
setInputValue(value);
}
setIsSuggestionsPanelOpen(false);
};
const handleSuggestionClick = async ({ value: address }: Suggestion) => {
try {
const location = await getLocationByAddress(ymaps, address);
const newValue = formatLocation(location);
setInputValue(newValue);
onChange(newValue);
} catch (error) {
console.error(error);
}
};
const { t } = useTranslation('~', {
keyPrefix: 'dry-wash.order-create.form.washing-location-field',
});
return (
<Box width='100%'>
<Popover
isOpen={isSuggestionsPanelOpen}
autoFocus={false}
placement='bottom-start'
>
<PopoverAnchor>
<Input
{...props}
ref={ref}
onBlur={onBlur}
value={inputValue}
onChange={onInputChange}
onFocus={onFocus}
placeholder={t('placeholder')}
/>
</PopoverAnchor>
<PopoverContent width='100%' maxWidth='100%'>
<PopoverBody border='1px' borderColor='gray.300' p={0}>
<List>
{suggestions.map((suggestion, index) => (
<ListItem
key={index}
p={2}
cursor='pointer'
_hover={{
bgColor: 'primary.50',
}}
_active={{
bgColor: 'primary.100',
}}
onClick={() => handleSuggestionClick(suggestion)}
>
{suggestion.displayName}
</ListItem>
))}
</List>
</PopoverBody>
</PopoverContent>
</Popover>
</Box>
);
}),
true,
['suggest', 'geocode'],
),
);
// todo: i18n
// todo: replace console.error with toast

View File

@@ -0,0 +1,8 @@
import { YMapsApi } from "@pbe/react-yandex-maps/typings/util/typing";
import { InputProps } from "@chakra-ui/react";
export type LocationInputProps = InputProps & {
ymaps?: YMapsApi;
value?: string;
onChange?: (value: string) => void;
};

View File

@@ -0,0 +1,10 @@
import { splitLocation } from "../helper";
import { Coordinate, Location, StringLocation } from "../types";
export const KAZAN_CITY_CENTER: Coordinate[] = [55.797557, 49.107295];
export const parseLocation = (stringifiedLocation: StringLocation): Location => {
const [coordinatesStr, address] = splitLocation(stringifiedLocation);
const [latitude, longitude] = coordinatesStr.split(',').map(Number);
return { coordinates: [latitude, longitude], address };
};

View File

@@ -0,0 +1 @@
export { MapComponent } from './map';

View File

@@ -0,0 +1,70 @@
import React, { FC, memo, useEffect, useState } from 'react';
import {
Map,
Placemark,
GeolocationControl,
ZoomControl,
withYMaps,
} from '@pbe/react-yandex-maps';
import { YMapsApi } from '@pbe/react-yandex-maps/typings/util/typing';
import { Coordinate, StringLocation } from '../types';
import { isValidLocation, stringifyLocation } from '../helper';
import { KAZAN_CITY_CENTER, parseLocation } from './helper';
export const MapComponent: FC<{
ymaps?: YMapsApi;
selectedLocation: StringLocation | null;
onMapClick: (props: StringLocation) => void;
}> = memo(
withYMaps(
({ ymaps, selectedLocation, onMapClick }) => {
const [mapCenter, setMapCenter] =
useState<Coordinate[]>(KAZAN_CITY_CENTER);
useEffect(() => {
if (isValidLocation(selectedLocation)) {
const location = parseLocation(selectedLocation);
const { coordinates } = location;
setMapCenter(coordinates);
}
}, [selectedLocation]);
return (
<Map
state={{
center: mapCenter,
zoom:
isValidLocation(selectedLocation) &&
parseLocation(selectedLocation)?.coordinates
? 17
: 10,
}}
width='100%'
onClick={async (e: ymaps.MapEvent) => {
try {
const coordinates = e.get('coords');
const address = await ymaps.geocode(coordinates).then((res) => {
const firstGeoObject = res.geoObjects.get(
0,
) as ymaps.GeocodeResult;
return firstGeoObject.getAddressLine();
});
onMapClick(stringifyLocation({ coordinates, address }));
setMapCenter(coordinates);
} catch (error) {
console.error(error);
}
}}
>
<GeolocationControl options={{ float: 'left' }} />
<ZoomControl />
{selectedLocation && <Placemark geometry={mapCenter} />}
</Map>
);
},
true,
['geocode'],
),
);

View File

@@ -0,0 +1,17 @@
export type Address = string;
export type Suggestion = {
value: Address;
displayName: string;
};
export type Coordinate = number;
export type Location = {
address: Address;
coordinates: [Coordinate, Coordinate];
};
export type StringCoordinates = `${Coordinate},${Coordinate}`;
export type StringLocation = `${StringCoordinates} ${Address}`;

View File

@@ -0,0 +1 @@
export { YMapsProvider } from './ymaps-provider';

View File

@@ -0,0 +1,16 @@
import { YMaps } from '@pbe/react-yandex-maps';
import React, { PropsWithChildren } from 'react';
export const YMapsProvider = ({ children }: PropsWithChildren) => {
return (
<YMaps
query={{
lang: 'ru_RU',
apikey: '19cbc387-db9a-4807-a620-5205215db40f',
suggest_apikey: '3576e65e-5e75-4ea2-b21c-7f9db112682e',
}}
>
{children}
</YMaps>
);
};

View File

@@ -1,14 +1,7 @@
import React, { FC } 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 { import { Box, Flex, FormControl, FormLabel, VStack } from '@chakra-ui/react';
Box,
Flex,
FormControl,
FormLabel,
Input,
VStack,
} from '@chakra-ui/react';
import { CarBodySelect } from './car-body'; import { CarBodySelect } from './car-body';
import { CarColorInput } from './car-color'; import { CarColorInput } from './car-color';
@@ -19,6 +12,7 @@ import { PhoneInput } from './phone';
import { SubmitButton } from './submit'; import { SubmitButton } from './submit';
import { defaultValues, onSubmit, useGetValidationRules } from './helper'; import { defaultValues, onSubmit, useGetValidationRules } from './helper';
import { DateTimeInput } from './date-time'; import { DateTimeInput } from './date-time';
import { LocationInput, MapComponent, StringLocation, YMapsProvider } from './location';
export const OrderForm: FC = () => { export const OrderForm: FC = () => {
const { const {
@@ -26,6 +20,7 @@ export const OrderForm: FC = () => {
control, control,
formState: { errors, isSubmitting }, formState: { errors, isSubmitting },
watch, watch,
setValue,
} = useForm<OrderFormValues>({ defaultValues, shouldFocusError: true }); } = useForm<OrderFormValues>({ defaultValues, shouldFocusError: true });
const { t } = useTranslation('~', { const { t } = useTranslation('~', {
@@ -34,9 +29,10 @@ export const OrderForm: FC = () => {
const RULES = useGetValidationRules(); const RULES = useGetValidationRules();
const [availableDatetimeBegin, availableDatetimeEnd] = watch([ const [availableDatetimeBegin, availableDatetimeEnd, carLocation] = watch([
'availableDatetimeBegin', 'availableDatetimeBegin',
'availableDatetimeEnd', 'availableDatetimeEnd',
'carLocation',
]); ]);
return ( return (
@@ -81,15 +77,6 @@ export const OrderForm: FC = () => {
errors={errors} errors={errors}
Input={CarBodySelect} Input={CarBodySelect}
/> />
<FormInputField
control={control}
name='carLocation'
label={t('washing-location-field.label')}
isRequired
errors={errors}
Input={Input}
help={t('washing-location-field.help')}
/>
<FormControl isRequired> <FormControl isRequired>
<FormLabel>{t('washing-datetime-field.label')}</FormLabel> <FormLabel>{t('washing-datetime-field.label')}</FormLabel>
<Flex flexWrap='wrap' gap={4}> <Flex flexWrap='wrap' gap={4}>
@@ -119,6 +106,23 @@ export const OrderForm: FC = () => {
</Box> </Box>
</Flex> </Flex>
</FormControl> </FormControl>
<YMapsProvider>
<FormInputField
control={control}
name='carLocation'
label={t('washing-location-field.label')}
help={t('washing-location-field.help')}
isRequired
errors={errors}
Input={LocationInput}
/>
<MapComponent
selectedLocation={carLocation as StringLocation}
onMapClick={(location) => {
setValue('carLocation', location);
}}
/>
</YMapsProvider>
<SubmitButton isLoading={isSubmitting} mt={4} /> <SubmitButton isLoading={isSubmitting} mt={4} />
</VStack> </VStack>
</Box> </Box>
@@ -126,5 +130,4 @@ export const OrderForm: FC = () => {
}; };
// todo: remove layout shift, when a validation message is displayed // todo: remove layout shift, when a validation message is displayed
// todo: select location using an interactive map // todo: fix time range available values
// todo: fix time range available values

34
src/keycloak.ts Normal file
View File

@@ -0,0 +1,34 @@
import Keycloak from 'keycloak-js';
const keycloak = new Keycloak({
url: 'https://kc.bro-js.ru',
realm: 'open',
clientId: 'dry-wash',
});
const authLogin = async ({ redirect }) => {
let user = null;
try {
const authenticated = await keycloak.init({ onLoad: 'login-required' });
if (authenticated) {
user = { ...(await keycloak.loadUserInfo()), ...keycloak.tokenParsed };
const isOperator =
user?.resource_access?.['dry-wash']?.roles.includes('operator');
if (!isOperator) {
redirect();
}
return user;
} else {
console.log('User is not authenticated');
}
} catch (error) {
keycloak.login();
console.error('Failed to initialize adapter:', error);
}
};
export default authLogin;

View File

@@ -1,8 +1,30 @@
import React from 'react'; import React, { useEffect, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { AbsoluteCenter, Spinner } from '@chakra-ui/react';
import LayoutArm from '../../components/LayoutArm'; import LayoutArm from '../../components/LayoutArm';
import authLogin from '../../keycloak';
import { URLs } from '../../__data__/urls';
const Page = () => { const Page = () => {
const [user, setUser] = useState(null);
const navigate = useNavigate();
useEffect(() => {
authLogin({ redirect: () => navigate(URLs.landing.url) }).then((user) =>
setUser(user),
);
}, []);
if (!user)
return (
<AbsoluteCenter>
<Spinner />
</AbsoluteCenter>
);
return <LayoutArm />; return <LayoutArm />;
}; };

View File

@@ -2,11 +2,15 @@ import React from 'react';
import { Text, Button, Center, VStack, Heading } from '@chakra-ui/react'; import { Text, Button, Center, VStack, Heading } from '@chakra-ui/react';
import { Link } from 'react-router-dom'; import { Link } from 'react-router-dom';
import { Player } from '@lottiefiles/react-lottie-player'; import { Player } from '@lottiefiles/react-lottie-player';
import i18next from 'i18next'; import { useTranslation } from 'react-i18next';
import animate from '../../assets/animation/notFound.json'; import animate from '../../assets/animation/notFound.json';
const NotFound = () => { const NotFound = () => {
const { t } = useTranslation('~', {
keyPrefix: 'dry-wash',
});
return ( return (
<Center minH='100vh'> <Center minH='100vh'>
<VStack spacing={4} textAlign='center'> <VStack spacing={4} textAlign='center'>
@@ -21,12 +25,8 @@ const NotFound = () => {
maxWidth: '450px', maxWidth: '450px',
}} }}
/> />
<Heading fontSize='xl'> <Heading fontSize='xl'>{t(`notFound.title`)}</Heading>
{i18next.t(`dry-wash.arm.notFound.title`)} <Text fontSize='lg'>{t(`notFound.description`)}</Text>
</Heading>
<Text fontSize='lg'>
{i18next.t(`dry-wash.arm.notFound.description`)}
</Text>
<Button <Button
as={Link} as={Link}
to='/dry-wash' to='/dry-wash'
@@ -34,7 +34,7 @@ const NotFound = () => {
size='lg' size='lg'
variant='outline' variant='outline'
> >
{i18next.t(`dry-wash.arm.notFound.button.back`)} {t(`notFound.button.back`)}
</Button> </Button>
</VStack> </VStack>
</Center> </Center>

38
stubs/api/admin.js Normal file
View File

@@ -0,0 +1,38 @@
/* eslint-disable no-undef */
/* eslint-disable @typescript-eslint/no-require-imports */
const router = require('express').Router();
const STUBS = { masters: 'success', orders: 'success' };
router.get('/set/:name/:value', (req, res) => {
const { name, value } = req.params;
STUBS[name] = value;
res.send('ok');
});
router.get('/', (req, res) => {
res.send(`<div>
<fieldset>
<legend>Мастера</legend>
${generateRadioInput('masters', 'success')}
${generateRadioInput('masters', 'error')}
</fieldset>
<fieldset>
<legend>Заказы</legend>
${generateRadioInput('orders', 'success')}
${generateRadioInput('orders', 'error')}
</fieldset>
</div>`);
});
module.exports = router;
module.exports.STUBS = STUBS;
function generateRadioInput(name, type) {
return `<label>
<input ${STUBS[name] === type ? 'checked' : ''} onclick="fetch('/api/admin/set/${name}/${type}')" type="radio" name="${name}">
${type}
</label>`;
}

View File

@@ -2,4 +2,37 @@
/* eslint-disable @typescript-eslint/no-require-imports */ /* eslint-disable @typescript-eslint/no-require-imports */
const router = require('express').Router(); const router = require('express').Router();
const STUBS = require('./admin').STUBS;
const commonError = { success: false, message: 'Что-то пошло не так' };
const sleep =
(duration = 1000) =>
(req, res, next) =>
setTimeout(next, duration);
router.use(sleep());
router.get('/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.post('/arm/orders', (req, res) => {
res
.status(/error/.test(STUBS.orders) ? 500 : 200)
.send(
/^error$/.test(STUBS.orders)
? commonError
: require(`../json/arm-orders/${STUBS.orders}.json`),
);
});
router.use('/admin', require('./admin'));
module.exports = router; module.exports = router;