diff --git a/front/src/api/api.ts b/front/src/api/api.ts new file mode 100644 index 0000000..b2eb307 --- /dev/null +++ b/front/src/api/api.ts @@ -0,0 +1,51 @@ +import { BASE_URL } from './constants'; +import { ApiResponse } from './types'; +import { unpack } from './utils'; + +const send = async ( + url: string, + init: RequestInit, +): Promise> => { + const fullURL = `${BASE_URL}/${url}`; + const fullInit: RequestInit = { ...init }; + try { + const response = await fetch(fullURL, fullInit); + if (!response.ok) { + return { + data: null, + error: { status: response.status, message: 'Something went wrong' }, + }; + } + const raw = await response.json(); + const data: T = unpack(raw); + return { data: data, error: null }; + } catch { + return { + data: null, + error: { status: 0, message: 'Something went wrong' }, + }; + } +}; + +export const api = { + get: async (url: string) => { + return send(url, { method: 'GET' }); + }, + post: async (url: string, body: unknown) => { + return send(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + put: async (url: string, body: unknown) => { + return send(url, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + }, + delete: async (url: string) => { + return send(url, { method: 'DELETE' }); + }, +}; diff --git a/front/src/api/constants.ts b/front/src/api/constants.ts new file mode 100644 index 0000000..ee3d5e9 --- /dev/null +++ b/front/src/api/constants.ts @@ -0,0 +1 @@ +export const BASE_URL = 'http://localhost:8000'; diff --git a/front/src/api/types.ts b/front/src/api/types.ts new file mode 100644 index 0000000..c2ff0b3 --- /dev/null +++ b/front/src/api/types.ts @@ -0,0 +1,9 @@ +export type ApiError = { + status: number; + message: string; +}; + +export type ApiResponse = { + data: T | null; + error: ApiError | null; +}; diff --git a/front/src/api/utils.ts b/front/src/api/utils.ts new file mode 100644 index 0000000..bad92dd --- /dev/null +++ b/front/src/api/utils.ts @@ -0,0 +1,23 @@ +export const toCamelCase = (str: string) => { + return str + .split(/[_\s-]+|(?=[A-Z])/) + .map((word, index) => + index === 0 + ? word.toLowerCase() + : word.charAt(0).toUpperCase() + word.slice(1).toLowerCase(), + ) + .join(''); +}; + +export const unpack = (obj: unknown) => { + if (Array.isArray(obj)) { + return obj.map((item) => unpack(item)); + } else if (obj !== null && typeof obj === 'object') { + return Object.entries(obj).reduce((acc, [key, value]) => { + const newKey = toCamelCase(key); + acc[newKey] = unpack(value); + return acc; + }, {}); + } + return obj; +}; diff --git a/front/src/api/wind/constants.ts b/front/src/api/wind/constants.ts new file mode 100644 index 0000000..529b26e --- /dev/null +++ b/front/src/api/wind/constants.ts @@ -0,0 +1,6 @@ +export const WIND_ENDPOINTS = { + turbines: 'api/wind/turbines', + turbineType: 'api/wind/turbine_type', + parks: 'api/wind/parks', + park: 'api/wind/park', +}; diff --git a/front/src/api/wind/index.ts b/front/src/api/wind/index.ts new file mode 100644 index 0000000..d851581 --- /dev/null +++ b/front/src/api/wind/index.ts @@ -0,0 +1,2 @@ +export * from './service'; +export { type Park, type TurbineType } from './types'; diff --git a/front/src/api/wind/service.ts b/front/src/api/wind/service.ts new file mode 100644 index 0000000..40d01af --- /dev/null +++ b/front/src/api/wind/service.ts @@ -0,0 +1,48 @@ +import { ParkFormValues, TurbineTypeFormValues } from '@components/ux'; + +import { api } from '../api'; +import { WIND_ENDPOINTS } from './constants'; +import { Park, ParkTurbine, TurbineType } from './types'; +import { packParkFormValues, packTurbineTypeFormValues } from './utils'; + +export const getTurbineTypes = () => { + return api.get(WIND_ENDPOINTS.turbines); +}; + +export const createTurbineTypes = (values: Partial) => { + return api.post( + WIND_ENDPOINTS.turbineType, + packTurbineTypeFormValues(values), + ); +}; + +export const editTurbineTypes = ( + values: Partial, + id: number, +) => { + const url = `${WIND_ENDPOINTS.turbineType}/${id}`; + return api.put(url, packTurbineTypeFormValues(values)); +}; + +export const deleteTurbineTypes = (id: number) => { + const url = `${WIND_ENDPOINTS.turbineType}/${id}`; + return api.delete(url); +}; + +export const getParks = () => { + return api.get(WIND_ENDPOINTS.parks); +}; + +export const createPark = (values: Partial) => { + return api.post(WIND_ENDPOINTS.park, packParkFormValues(values)); +}; + +export const editPark = (values: Partial, id: number) => { + const url = `${WIND_ENDPOINTS.park}/${id}`; + return api.put(url, packParkFormValues(values)); +}; + +export const getParkTurines = (id: number) => { + const url = `${WIND_ENDPOINTS.parks}/${id}/turbines`; + return api.get(url); +}; diff --git a/front/src/api/wind/types.ts b/front/src/api/wind/types.ts new file mode 100644 index 0000000..b2a1627 --- /dev/null +++ b/front/src/api/wind/types.ts @@ -0,0 +1,22 @@ +export type TurbineType = { + id: number; + name: string; + height: number; + bladeLength: number; +}; + +export type Park = { + id: number; + name: string; + centerLatitude: number; + centerLongitude: number; +}; + +export type ParkTurbine = { + windParkId: number; + turbineId: number; + xOffset: number; + yOffset: number; + angle: number; + comment: string; +}; diff --git a/front/src/api/wind/utils.ts b/front/src/api/wind/utils.ts new file mode 100644 index 0000000..1c2eb3a --- /dev/null +++ b/front/src/api/wind/utils.ts @@ -0,0 +1,19 @@ +import { ParkFormValues, TurbineTypeFormValues } from '@components/ux'; + +export const packTurbineTypeFormValues = ( + values: Partial, +) => { + return { + Name: values.name ?? '', + Height: parseInt(values.height ?? '0'), + BladeLength: parseInt(values.bladeLength ?? '0'), + }; +}; + +export const packParkFormValues = (values: Partial) => { + return { + Name: values.name ?? '', + CenterLatitude: parseInt(values.centerLatitude ?? '0'), + CenterLongitude: parseInt(values.centerLongitude ?? '0'), + }; +}; diff --git a/front/src/components/app/index.tsx b/front/src/components/app/index.tsx index 75b826d..bad3af7 100644 --- a/front/src/components/app/index.tsx +++ b/front/src/components/app/index.tsx @@ -2,18 +2,27 @@ import './styles.scss'; import '@public/fonts/styles.css'; import { MainLayout } from '@components/layouts'; -import { FormPage, HomePage } from '@components/pages'; +import { ParksPage, TurbineTypesPage } from '@components/pages'; +import { ROUTES } from '@utils/route'; import React from 'react'; -import { BrowserRouter, Route, Routes } from 'react-router-dom'; +import { BrowserRouter, Navigate, Route, Routes } from 'react-router-dom'; function App() { return ( }> - } /> - } /> + } + /> + } + /> + } /> + } /> ); diff --git a/front/src/components/layouts/main-layout/index.tsx b/front/src/components/layouts/main-layout/index.tsx index c438cde..f6033b7 100644 --- a/front/src/components/layouts/main-layout/index.tsx +++ b/front/src/components/layouts/main-layout/index.tsx @@ -9,7 +9,9 @@ function MainLayout() {
- +
+ +
); diff --git a/front/src/components/layouts/main-layout/styles.module.scss b/front/src/components/layouts/main-layout/styles.module.scss index 1b778a6..0a2613b 100644 --- a/front/src/components/layouts/main-layout/styles.module.scss +++ b/front/src/components/layouts/main-layout/styles.module.scss @@ -7,6 +7,15 @@ } .main { + display: flex; overflow: auto; height: 100%; + justify-content: center; +} + +.content { + display: grid; + width: 1000px; + grid-template-columns: minmax(0, 1fr); + grid-template-rows: minmax(0, 1fr); } diff --git a/front/src/components/pages/form-page/component.tsx b/front/src/components/pages/form-page/component.tsx deleted file mode 100644 index ed95963..0000000 --- a/front/src/components/pages/form-page/component.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { SignInForm } from '@components/ux'; -import React from 'react'; - -import styles from './styles.module.scss'; - -export function FormPage() { - return ( -
- -
- ); -} diff --git a/front/src/components/pages/form-page/index.ts b/front/src/components/pages/form-page/index.ts deleted file mode 100644 index e136094..0000000 --- a/front/src/components/pages/form-page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { FormPage } from './component'; diff --git a/front/src/components/pages/form-page/styles.module.scss b/front/src/components/pages/form-page/styles.module.scss deleted file mode 100644 index 00762eb..0000000 --- a/front/src/components/pages/form-page/styles.module.scss +++ /dev/null @@ -1,11 +0,0 @@ -.about { - display: grid; - padding: 20px; - grid-template: - '. form .' auto - / auto minmax(0, 380px) auto; -} - -.form { - grid-area: form; -} diff --git a/front/src/components/pages/home-page/component.tsx b/front/src/components/pages/home-page/component.tsx deleted file mode 100644 index 2527f5b..0000000 --- a/front/src/components/pages/home-page/component.tsx +++ /dev/null @@ -1,36 +0,0 @@ -import { AutocompletePreview } from '@components/ui/autocomplete'; -import { ButtonPreview } from '@components/ui/button'; -import { CheckboxGroupPreview } from '@components/ui/checkbox-group'; -import { DataGridPreview } from '@components/ui/data-grid'; -import { DateInputPreview } from '@components/ui/date-input'; -import { ImageFileManagerPreview } from '@components/ui/image-file-manager/preview'; -import { NumberInputPreview } from '@components/ui/number-input/preview'; -import { PasswordInputPreview } from '@components/ui/password-input'; -import { RadioGroupPreview } from '@components/ui/radio-group'; -import { SelectPreview } from '@components/ui/select'; -import { TextAreaPreview } from '@components/ui/text-area/preview'; -import { TextInputPreview } from '@components/ui/text-input'; -import React from 'react'; - -import styles from './styles.module.scss'; - -export function HomePage() { - return ( -
-
- - - - - - - - - - - - -
-
- ); -} diff --git a/front/src/components/pages/home-page/index.ts b/front/src/components/pages/home-page/index.ts deleted file mode 100644 index e2c7c55..0000000 --- a/front/src/components/pages/home-page/index.ts +++ /dev/null @@ -1 +0,0 @@ -export { HomePage } from './component'; diff --git a/front/src/components/pages/home-page/styles.module.scss b/front/src/components/pages/home-page/styles.module.scss deleted file mode 100644 index 5a8812c..0000000 --- a/front/src/components/pages/home-page/styles.module.scss +++ /dev/null @@ -1,14 +0,0 @@ -.home { - display: grid; - grid-template: - '. content .' auto - / auto minmax(0, 1000px) auto; -} - -.content { - display: flex; - flex-direction: column; - padding: 20px 20px 60px 20px; - gap: 30px; - grid-area: content; -} diff --git a/front/src/components/pages/index.ts b/front/src/components/pages/index.ts new file mode 100644 index 0000000..f1c6e0b --- /dev/null +++ b/front/src/components/pages/index.ts @@ -0,0 +1,2 @@ +export * from './parks-page'; +export * from './turbine-types-page'; diff --git a/front/src/components/pages/index.tsx b/front/src/components/pages/index.tsx deleted file mode 100644 index 449826b..0000000 --- a/front/src/components/pages/index.tsx +++ /dev/null @@ -1,2 +0,0 @@ -export { FormPage } from './form-page'; -export { HomePage } from './home-page'; diff --git a/front/src/components/pages/parks-page/component.tsx b/front/src/components/pages/parks-page/component.tsx new file mode 100644 index 0000000..7484ffd --- /dev/null +++ b/front/src/components/pages/parks-page/component.tsx @@ -0,0 +1,81 @@ +import { Button, Heading } from '@components/ui'; +import { DataGrid } from '@components/ui/data-grid'; +import { ParkModal } from '@components/ux'; +import { useRoute } from '@utils/route'; +import React, { useEffect, useState } from 'react'; +import { getParks, Park } from 'src/api/wind'; + +import { columns } from './constants'; +import styles from './styles.module.scss'; + +export function ParksPage() { + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [parks, setParks] = useState([]); + const [selected, setSelected] = useState(null); + const route = useRoute(); + + const fetchParks = async () => { + const res = await getParks(); + setParks(res.data ?? []); + }; + + useEffect(() => { + fetchParks(); + }, []); + + const handleCreateButtonClick = () => { + setCreateModalOpen(true); + }; + + const handleEditButtonClick = () => { + setEditModalOpen(true); + }; + + const handleParkSelect = (items: Park[]) => { + setSelected(items[0] ?? null); + }; + + return ( +
+ + {route.title} + +
+ + + +
+
+ String(id)} + selectedItems={selected ? [selected] : []} + onItemsSelect={handleParkSelect} + multiselect={false} + /> +
+ setCreateModalOpen(false)} + onSuccess={fetchParks} + /> + setEditModalOpen(false)} + onSuccess={fetchParks} + /> +
+ ); +} diff --git a/front/src/components/pages/parks-page/constants.ts b/front/src/components/pages/parks-page/constants.ts new file mode 100644 index 0000000..ea8e9c1 --- /dev/null +++ b/front/src/components/pages/parks-page/constants.ts @@ -0,0 +1,8 @@ +import { DataGridColumnConfig } from '@components/ui/data-grid/types'; +import { Park } from 'src/api/wind'; + +export const columns: DataGridColumnConfig[] = [ + { name: 'Name', getText: (t) => t.name, flex: '2' }, + { name: 'Center latitude', getText: (t) => String(t.centerLatitude) }, + { name: 'Center longitude', getText: (t) => String(t.centerLongitude) }, +]; diff --git a/front/src/components/pages/parks-page/index.ts b/front/src/components/pages/parks-page/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/pages/parks-page/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/pages/parks-page/styles.module.scss b/front/src/components/pages/parks-page/styles.module.scss new file mode 100644 index 0000000..5d18482 --- /dev/null +++ b/front/src/components/pages/parks-page/styles.module.scss @@ -0,0 +1,20 @@ +.page { + display: grid; + padding: 40px 20px 20px; + gap: 20px; + grid-template-rows: auto auto minmax(0, 1fr); +} + +.dataGridWrapper { + overflow: auto; +} + +.actions { + display: flex; + padding: 10px; + border: 1px solid var(--clr-border-100); + border-radius: 10px; + background-color: var(--clr-layer-200); + box-shadow: 0px 1px 2px var(--clr-shadow-100); + gap: 10px; +} diff --git a/front/src/components/pages/turbine-types-page/component.tsx b/front/src/components/pages/turbine-types-page/component.tsx new file mode 100644 index 0000000..40e84b4 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/component.tsx @@ -0,0 +1,90 @@ +import { Button, Heading } from '@components/ui'; +import { DataGrid } from '@components/ui/data-grid'; +import { TurbineTypeModal } from '@components/ux'; +import { useRoute } from '@utils/route'; +import React, { useEffect, useState } from 'react'; +import { deleteTurbineTypes, getTurbineTypes, TurbineType } from 'src/api/wind'; + +import { columns } from './constants'; +import styles from './styles.module.scss'; + +export function TurbineTypesPage() { + const [createModalOpen, setCreateModalOpen] = useState(false); + const [editModalOpen, setEditModalOpen] = useState(false); + const [turbineTypes, setTurbineTypes] = useState([]); + const [selected, setSelected] = useState(null); + const route = useRoute(); + + const fetchTurbineTypes = async () => { + const res = await getTurbineTypes(); + setTurbineTypes(res.data ?? []); + }; + + useEffect(() => { + fetchTurbineTypes(); + }, []); + + const handleCreateButtonClick = () => { + setCreateModalOpen(true); + }; + + const handleEditButtonClick = () => { + setEditModalOpen(true); + }; + + const handleDeleteButtonClick = async () => { + await deleteTurbineTypes(selected.id); + fetchTurbineTypes(); + }; + + const handleTurbineTypeSelect = (items: TurbineType[]) => { + setSelected(items[0] ?? null); + }; + + return ( +
+ + {route.title} + +
+ + + +
+
+ String(id)} + selectedItems={selected ? [selected] : []} + onItemsSelect={handleTurbineTypeSelect} + multiselect={false} + /> +
+ setCreateModalOpen(false)} + onSuccess={fetchTurbineTypes} + /> + setEditModalOpen(false)} + onSuccess={fetchTurbineTypes} + /> +
+ ); +} diff --git a/front/src/components/pages/turbine-types-page/constants.ts b/front/src/components/pages/turbine-types-page/constants.ts new file mode 100644 index 0000000..06af2a6 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/constants.ts @@ -0,0 +1,8 @@ +import { DataGridColumnConfig } from '@components/ui/data-grid/types'; +import { TurbineType } from 'src/api/wind'; + +export const columns: DataGridColumnConfig[] = [ + { name: 'Name', getText: (t) => t.name, flex: '2' }, + { name: 'Height', getText: (t) => String(t.height) }, + { name: 'Blade length', getText: (t) => String(t.bladeLength) }, +]; diff --git a/front/src/components/pages/turbine-types-page/index.ts b/front/src/components/pages/turbine-types-page/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/pages/turbine-types-page/styles.module.scss b/front/src/components/pages/turbine-types-page/styles.module.scss new file mode 100644 index 0000000..5d18482 --- /dev/null +++ b/front/src/components/pages/turbine-types-page/styles.module.scss @@ -0,0 +1,20 @@ +.page { + display: grid; + padding: 40px 20px 20px; + gap: 20px; + grid-template-rows: auto auto minmax(0, 1fr); +} + +.dataGridWrapper { + overflow: auto; +} + +.actions { + display: flex; + padding: 10px; + border: 1px solid var(--clr-border-100); + border-radius: 10px; + background-color: var(--clr-layer-200); + box-shadow: 0px 1px 2px var(--clr-shadow-100); + gap: 10px; +} diff --git a/front/src/components/ui/animation/fade/styles.module.scss b/front/src/components/ui/animation/fade/styles.module.scss index 6a940c6..6a4275d 100644 --- a/front/src/components/ui/animation/fade/styles.module.scss +++ b/front/src/components/ui/animation/fade/styles.module.scss @@ -9,21 +9,17 @@ @keyframes fadein { from { opacity: 0; - transform: scale(0.9) translateY(-30px); } to { opacity: 1; - transform: scale(1) translateY(0); } } @keyframes fadeout { from { opacity: 1; - transform: scale(1) translateY(0); } to { opacity: 0; - transform: scale(0.9) translateY(-30px); } } diff --git a/front/src/components/ui/animation/index.ts b/front/src/components/ui/animation/index.ts index bfdbf75..e6ec28a 100644 --- a/front/src/components/ui/animation/index.ts +++ b/front/src/components/ui/animation/index.ts @@ -1,2 +1,3 @@ export * from './fade'; export * from './ripple'; +export * from './slide'; diff --git a/front/src/components/ui/animation/ripple/component.tsx b/front/src/components/ui/animation/ripple/component.tsx index 0faee3a..00fa127 100644 --- a/front/src/components/ui/animation/ripple/component.tsx +++ b/front/src/components/ui/animation/ripple/component.tsx @@ -50,7 +50,6 @@ export function Ripple() { }; const handlePointerDown = (event: React.MouseEvent) => { - event.stopPropagation(); const { pageX, pageY } = event; addWave(pageX, pageY); }; diff --git a/front/src/components/ui/animation/slide/component.tsx b/front/src/components/ui/animation/slide/component.tsx new file mode 100644 index 0000000..7c07f49 --- /dev/null +++ b/front/src/components/ui/animation/slide/component.tsx @@ -0,0 +1,58 @@ +import clsx from 'clsx'; +import React, { ForwardedRef, forwardRef, useEffect, useState } from 'react'; + +import styles from './styles.module.scss'; +import { SlideProps } from './types'; + +export function SlideInner( + { + visible, + duration = 200, + className, + style, + ...props + }: Omit, + ref: ForwardedRef, +) { + const [visibleInner, setVisibleInner] = useState(visible); + + const classNames = clsx( + styles.slide, + { [styles.invisible]: !visible }, + className, + ); + + const inlineStyle = { + ...style, + '--animation-duration': `${duration}ms`, + } as React.CSSProperties; + + useEffect(() => { + if (visible) { + setVisibleInner(true); + return; + } + }, [visible]); + + const handleAnimationEnd = (event: React.AnimationEvent) => { + if (event.animationName === styles.fadeout) { + setVisibleInner(false); + } + }; + + if (!visibleInner) { + return null; + } + + return ( +
+ ); +} + +export const Slide = forwardRef(SlideInner); diff --git a/front/src/components/ui/animation/slide/index.ts b/front/src/components/ui/animation/slide/index.ts new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/animation/slide/index.ts @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/animation/slide/styles.module.scss b/front/src/components/ui/animation/slide/styles.module.scss new file mode 100644 index 0000000..c4d3186 --- /dev/null +++ b/front/src/components/ui/animation/slide/styles.module.scss @@ -0,0 +1,29 @@ +.slide { + animation: fadein var(--animation-duration); +} + +.invisible { + animation: fadeout var(--animation-duration) forwards ease-in-out; +} + +@keyframes fadein { + from { + opacity: 0; + transform: scale(0.9) translateY(-30px); + } + to { + opacity: 1; + transform: scale(1) translateY(0); + } +} + +@keyframes fadeout { + from { + opacity: 1; + transform: scale(1) translateY(0); + } + to { + opacity: 0; + transform: scale(0.9) translateY(-30px); + } +} diff --git a/front/src/components/ui/animation/slide/types.ts b/front/src/components/ui/animation/slide/types.ts new file mode 100644 index 0000000..9c869cd --- /dev/null +++ b/front/src/components/ui/animation/slide/types.ts @@ -0,0 +1,4 @@ +export type SlideProps = { + visible: boolean; + duration?: number; +} & React.ComponentProps<'div'>; diff --git a/front/src/components/ui/autocomplete/component.tsx b/front/src/components/ui/autocomplete/component.tsx index 4d28843..7d8cc44 100644 --- a/front/src/components/ui/autocomplete/component.tsx +++ b/front/src/components/ui/autocomplete/component.tsx @@ -26,6 +26,8 @@ function AutocompleteInner( label = {}, name, id, + className, + ...props }: Omit, 'ref'>, ref: ForwardedRef, ) { @@ -43,9 +45,12 @@ function AutocompleteInner( menuVisible, ); - const autocompleteClassName = clsx(styles.autocomplete, styles[scale], { - [styles.menuVisible]: menuVisible, - }); + const autocompleteClassName = clsx( + styles.autocomplete, + styles[scale], + { [styles.menuVisible]: menuVisible }, + className, + ); const filteredOptions = options.filter((option) => { const label = getOptionLabel(option).toLocaleLowerCase(); @@ -75,7 +80,7 @@ function AutocompleteInner( }; return ( -
+
({ items, columns, + getItemKey, className, + selectedItems, + onItemsSelect, + multiselect = true, ...props }: DataGridProps) { - const [selectedRows, setSelectedRows] = useState>({}); const [allRowsSelected, setAllRowsSelected] = useState(false); + const selectedItemsMap = useMemo( + () => arrayToObject(selectedItems, (i) => getItemKey(i)), + [selectedItems], + ); + const handleSelectAllRows = () => { - const newSelectedRows: Record = {}; - items.forEach((_, index) => { - newSelectedRows[index] = !allRowsSelected; - }); - setSelectedRows(newSelectedRows); + if (!multiselect) { + return; + } setAllRowsSelected(!allRowsSelected); + if (allRowsSelected) { + onItemsSelect([]); + } else { + onItemsSelect([...items]); + } }; - const handleRowSelect = (rowIndex: number) => { - setSelectedRows({ - ...selectedRows, - [rowIndex]: selectedRows[rowIndex] ? !selectedRows[rowIndex] : true, - }); + const handleRowSelect = (item: T) => { setAllRowsSelected(false); + const key = getItemKey(item); + const selected = selectedItemsMap[key]; + if (!multiselect) { + onItemsSelect(selected ? [] : [item]); + } else { + onItemsSelect( + selected + ? selectedItems.filter((i) => key !== getItemKey(i)) + : [...selectedItems, item], + ); + } }; return ( -
+
- {items.map((item, index) => ( - handleRowSelect(index)} - key={index} - /> - ))} + {items.map((item) => { + const key = getItemKey(item); + return ( + handleRowSelect(item)} + key={key} + /> + ); + })}
); } diff --git a/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss index 4888bd1..16cb851 100644 --- a/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss +++ b/front/src/components/ui/data-grid/components/DataGridHeader/styles.module.scss @@ -6,7 +6,6 @@ padding: 10px; border: solid 1px var(--clr-border-100); background-color: var(--clr-layer-300); - border-top-left-radius: 10px; } .cell { @@ -19,16 +18,13 @@ border: solid 1px var(--clr-border-100); background-color: var(--clr-layer-300); cursor: pointer; + font-weight: 500; gap: 10px; transition: all var(--td-100) ease-in-out; &:hover { background-color: var(--clr-layer-300-hover); } - - &:last-of-type { - border-top-right-radius: 10px; - } } .name { diff --git a/front/src/components/ui/data-grid/components/DataGridRow/component.tsx b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx index c55621e..3bf6f7c 100644 --- a/front/src/components/ui/data-grid/components/DataGridRow/component.tsx +++ b/front/src/components/ui/data-grid/components/DataGridRow/component.tsx @@ -24,7 +24,7 @@ export function DataGridRow({ style={{ flex: column.flex }} key={column.name} > - {column.getText(object)} + {column.getText(object)}
))}
diff --git a/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss index 6cc471e..43a89f8 100644 --- a/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss +++ b/front/src/components/ui/data-grid/components/DataGridRow/styles.module.scss @@ -5,6 +5,7 @@ .checkboxLabel { padding: 10px; border: solid 1px var(--clr-border-100); + background-color: var(--clr-layer-200); } .cell { @@ -14,5 +15,6 @@ align-items: center; padding: 10px; border: solid 1px var(--clr-border-100); + background-color: var(--clr-layer-200); overflow-wrap: anywhere; } diff --git a/front/src/components/ui/data-grid/styles.module.scss b/front/src/components/ui/data-grid/styles.module.scss new file mode 100644 index 0000000..e67efd1 --- /dev/null +++ b/front/src/components/ui/data-grid/styles.module.scss @@ -0,0 +1,4 @@ +.dataGrid { + border-radius: 10px; + box-shadow: 0px 2px 2px var(--clr-shadow-200); +} diff --git a/front/src/components/ui/data-grid/types.ts b/front/src/components/ui/data-grid/types.ts index 7f49f4e..45e0a5e 100644 --- a/front/src/components/ui/data-grid/types.ts +++ b/front/src/components/ui/data-grid/types.ts @@ -13,6 +13,10 @@ export type DataGridSort = { export type DataGridProps = { items: T[]; columns: DataGridColumnConfig[]; + getItemKey: (object: T) => string; + selectedItems: T[]; + onItemsSelect: (selectedItems: T[]) => void; + multiselect?: boolean; } & React.ComponentPropsWithoutRef<'div'>; export type Cat = { diff --git a/front/src/components/ui/index.tsx b/front/src/components/ui/index.tsx index 9ce5c78..503045e 100644 --- a/front/src/components/ui/index.tsx +++ b/front/src/components/ui/index.tsx @@ -9,6 +9,7 @@ export { IconButton } from './icon-button'; export { ImageFileManager } from './image-file-manager'; export { Menu } from './menu'; export { NumberInput } from './number-input'; +export { Overlay } from './overlay'; export { Paragraph } from './paragraph'; export { PasswordInput } from './password-input'; export { RadioGroup } from './radio-group'; diff --git a/front/src/components/ui/overlay/component.tsx b/front/src/components/ui/overlay/component.tsx new file mode 100644 index 0000000..f35c483 --- /dev/null +++ b/front/src/components/ui/overlay/component.tsx @@ -0,0 +1,16 @@ +import clsx from 'clsx'; +import React from 'react'; +import { createPortal } from 'react-dom'; + +import { Fade } from '../animation'; +import styles from './styles.module.scss'; +import { OverlayProps } from './types'; + +export function Overlay({ open, children, variant = 'small' }: OverlayProps) { + return createPortal( + +
{children}
+
, + document.body, + ); +} diff --git a/front/src/components/ui/overlay/index.tsx b/front/src/components/ui/overlay/index.tsx new file mode 100644 index 0000000..bb82484 --- /dev/null +++ b/front/src/components/ui/overlay/index.tsx @@ -0,0 +1 @@ +export * from './component'; diff --git a/front/src/components/ui/overlay/styles.module.scss b/front/src/components/ui/overlay/styles.module.scss new file mode 100644 index 0000000..a48431c --- /dev/null +++ b/front/src/components/ui/overlay/styles.module.scss @@ -0,0 +1,27 @@ +.backdrop { + position: absolute; + top: 0; + left: 0; + display: grid; + width: 100%; + height: 100%; + padding: 20px; + backdrop-filter: blur(5px); + background-color: rgba(0 0 0 / 0.75); +} + +.small { + grid-template: + '. . .' 1fr + '. form .' auto + '. . .' 5fr + / 1fr minmax(0, 400px) 1fr; +} + +.large { + grid-template: + '. . .' 1fr + '. form .' auto + '. . .' 5fr + / 1fr minmax(0, 600px) 1fr; +} diff --git a/front/src/components/ui/overlay/types.ts b/front/src/components/ui/overlay/types.ts new file mode 100644 index 0000000..9f40902 --- /dev/null +++ b/front/src/components/ui/overlay/types.ts @@ -0,0 +1,7 @@ +import { ReactNode } from 'react'; + +export type OverlayProps = { + open: boolean; + children: ReactNode; + variant?: 'small' | 'large'; +}; diff --git a/front/src/components/ui/popover/component.tsx b/front/src/components/ui/popover/component.tsx index 351dfd6..6edcc39 100644 --- a/front/src/components/ui/popover/component.tsx +++ b/front/src/components/ui/popover/component.tsx @@ -7,7 +7,7 @@ import React, { } from 'react'; import { createPortal } from 'react-dom'; -import { Fade } from '../animation'; +import { Slide } from '../animation'; import styles from './styles.module.scss'; import { PopoverProps } from './types'; import { calcFadeStyles } from './utils'; @@ -58,7 +58,7 @@ export function Popover({ } return createPortal( - {element} - , + , document.body, ); } diff --git a/front/src/components/ux/index.ts b/front/src/components/ux/index.ts new file mode 100644 index 0000000..dedded2 --- /dev/null +++ b/front/src/components/ux/index.ts @@ -0,0 +1,4 @@ +export * from './header'; +export * from './park-modal'; +export * from './sidebar'; +export * from './turbine-type-modal'; diff --git a/front/src/components/ux/index.tsx b/front/src/components/ux/index.tsx deleted file mode 100644 index 69cb7db..0000000 --- a/front/src/components/ux/index.tsx +++ /dev/null @@ -1,4 +0,0 @@ -export * from './header'; -export * from './sidebar'; -export * from './sign-in-form'; -export * from './theme-select'; diff --git a/front/src/components/ux/park-modal/component.tsx b/front/src/components/ux/park-modal/component.tsx new file mode 100644 index 0000000..261c75c --- /dev/null +++ b/front/src/components/ux/park-modal/component.tsx @@ -0,0 +1,115 @@ +import { + Autocomplete, + Button, + Heading, + NumberInput, + Overlay, + TextInput, +} from '@components/ui'; +import { Controller, useForm } from '@utils/form'; +import React, { useEffect, useState } from 'react'; +import { + createPark, + editPark, + getParkTurines, + getTurbineTypes, + TurbineType, +} from 'src/api/wind'; +import { ParkTurbine } from 'src/api/wind/types'; + +import styles from './styles.module.scss'; +import { ParkFormValues, ParkModalProps } from './types'; +import { parkToFormValues } from './utils'; + +export function ParkModal({ park, open, onClose, onSuccess }: ParkModalProps) { + const [turbineTypes, setTurbineTypes] = useState([]); + const [parkTurbines, setParkTurbines] = useState([]); + const [pending, setPending] = useState(false); + const isEdit = park !== null; + + console.log(parkTurbines); + + const { register, getValues, reset, control } = useForm({ + initialValues: {}, + }); + + const fetchTurbineTypes = async () => { + const res = await getTurbineTypes(); + setTurbineTypes(res.data ?? []); + }; + + const fetchParkTurbines = async () => { + if (!park) { + return; + } + const res = await getParkTurines(park.id); + setParkTurbines(res.data ?? []); + }; + + useEffect(() => { + fetchTurbineTypes(); + }, []); + + useEffect(() => { + reset(park ? parkToFormValues(park) : {}); + fetchParkTurbines(); + }, [park]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setPending(true); + if (isEdit) { + await editPark(getValues(), park.id); + } else { + await createPark(getValues()); + } + setPending(false); + onClose(); + onSuccess(); + }; + + return ( + +
+ + {isEdit ? 'Edit' : 'Create new'} + +
+ +
+ ( + + )} + /> + ( + + )} + /> +
+
+ id} + getOptionLabel={({ name }) => name} + className={styles.autocomplete} + label={{ text: 'Turbines' }} + /> + +
+
+
+ + +
+
+
+ ); +} diff --git a/front/src/components/ux/park-modal/index.ts b/front/src/components/ux/park-modal/index.ts new file mode 100644 index 0000000..c01846b --- /dev/null +++ b/front/src/components/ux/park-modal/index.ts @@ -0,0 +1,2 @@ +export * from './component'; +export { type ParkFormValues } from './types'; diff --git a/front/src/components/ux/park-modal/styles.module.scss b/front/src/components/ux/park-modal/styles.module.scss new file mode 100644 index 0000000..7cb8e4f --- /dev/null +++ b/front/src/components/ux/park-modal/styles.module.scss @@ -0,0 +1,48 @@ +.form { + display: grid; + padding: 40px 20px; + border-radius: 10px; + background-color: var(--clr-layer-200); + box-shadow: 0px 1px 2px var(--clr-shadow-100); + gap: 40px; + grid-area: form; + grid-template-rows: auto 1fr auto; + + & > * { + width: 100%; + } +} + +.heading { + text-align: center; +} + +.inputBox { + display: grid; + gap: 20px; +} + +.duo { + display: flex; + gap: 10px; + + & > * { + flex: 1; + } +} + +.autocompleteBox { + display: flex; + align-items: flex-end; + gap: 10px; +} + +.autocomplete { + flex: 1; +} + +.buttonBox { + display: flex; + justify-content: end; + gap: 10px; +} diff --git a/front/src/components/ux/park-modal/types.ts b/front/src/components/ux/park-modal/types.ts new file mode 100644 index 0000000..5893ed8 --- /dev/null +++ b/front/src/components/ux/park-modal/types.ts @@ -0,0 +1,14 @@ +import { Park } from 'src/api/wind'; + +export type ParkFormValues = { + name: string; + centerLatitude: string; + centerLongitude: string; +}; + +export type ParkModalProps = { + park: Park; + open: boolean; + onClose: () => void; + onSuccess: () => void; +}; diff --git a/front/src/components/ux/park-modal/utils.ts b/front/src/components/ux/park-modal/utils.ts new file mode 100644 index 0000000..78490af --- /dev/null +++ b/front/src/components/ux/park-modal/utils.ts @@ -0,0 +1,11 @@ +import { Park } from 'src/api/wind'; + +import { ParkFormValues } from './types'; + +export const parkToFormValues = (park: Park): ParkFormValues => { + return { + name: park.name, + centerLatitude: String(park.centerLatitude), + centerLongitude: String(park.centerLongitude), + }; +}; diff --git a/front/src/components/ux/sidebar/component.tsx b/front/src/components/ux/sidebar/component.tsx index 713fb96..77c7f25 100644 --- a/front/src/components/ux/sidebar/component.tsx +++ b/front/src/components/ux/sidebar/component.tsx @@ -1,4 +1,5 @@ import { Heading } from '@components/ui'; +import { useRoute } from '@utils/route'; import React from 'react'; import { Link } from 'react-router-dom'; @@ -7,6 +8,9 @@ import { links } from './constants'; import styles from './styles.module.scss'; export function Sidebar() { + const route = useRoute(); + console.log(route); + return (
Wind App diff --git a/front/src/components/ux/sidebar/constants.ts b/front/src/components/ux/sidebar/constants.ts index 556b4b0..ee7c3c6 100644 --- a/front/src/components/ux/sidebar/constants.ts +++ b/front/src/components/ux/sidebar/constants.ts @@ -1,6 +1,8 @@ +import { ROUTES } from '@utils/route'; + import { SidebarLink } from './types'; export const links: SidebarLink[] = [ - { url: '/turbine-types', title: 'Turbine types' }, - { url: '/parks', title: 'Parks' }, + { url: ROUTES.turbineTypes.path, title: ROUTES.turbineTypes.title }, + { url: ROUTES.parks.path, title: ROUTES.parks.title }, ]; diff --git a/front/src/components/ux/sidebar/styles.module.scss b/front/src/components/ux/sidebar/styles.module.scss index 8b9ffcb..5d9099f 100644 --- a/front/src/components/ux/sidebar/styles.module.scss +++ b/front/src/components/ux/sidebar/styles.module.scss @@ -2,6 +2,7 @@ display: flex; flex-direction: column; padding: 20px; + border-right: 1px solid var(--clr-border-100); background-color: var(--clr-layer-200); box-shadow: 0px 1px 2px var(--clr-shadow-100); gap: 20px; diff --git a/front/src/components/ux/sign-in-form/component.tsx b/front/src/components/ux/sign-in-form/component.tsx deleted file mode 100644 index 8c1929b..0000000 --- a/front/src/components/ux/sign-in-form/component.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { Button, Heading, PasswordInput, TextInput } from '@components/ui'; -import { useForm } from '@utils/form'; -import clsx from 'clsx'; -import React from 'react'; - -import { initialValues } from './constants'; -import styles from './styles.module.scss'; -import { SignInFormProps, SignInFormStore } from './types'; - -export function SignInForm({ className, ...props }: SignInFormProps) { - const { register, getValues } = useForm({ - initialValues, - }); - const classNames = clsx(className, styles.form); - - const handleSubmit = (event: React.FormEvent) => { - event.preventDefault(); - console.log(getValues()); - }; - - return ( -
- - Sign in - -
- - -
- -
- ); -} diff --git a/front/src/components/ux/sign-in-form/constants.ts b/front/src/components/ux/sign-in-form/constants.ts deleted file mode 100644 index bc4e620..0000000 --- a/front/src/components/ux/sign-in-form/constants.ts +++ /dev/null @@ -1,7 +0,0 @@ -import { FormValues } from '@utils/form'; - -import { SignInFormStore } from './types'; - -export const initialValues: FormValues = { - email: 'aaa', -}; diff --git a/front/src/components/ux/sign-in-form/index.tsx b/front/src/components/ux/sign-in-form/index.tsx deleted file mode 100644 index fba6544..0000000 --- a/front/src/components/ux/sign-in-form/index.tsx +++ /dev/null @@ -1 +0,0 @@ -export { SignInForm } from './component'; diff --git a/front/src/components/ux/sign-in-form/types.ts b/front/src/components/ux/sign-in-form/types.ts deleted file mode 100644 index 10862af..0000000 --- a/front/src/components/ux/sign-in-form/types.ts +++ /dev/null @@ -1,6 +0,0 @@ -export type SignInFormStore = { - email: string; - password: string; -}; - -export type SignInFormProps = {} & React.ComponentProps<'form'>; diff --git a/front/src/components/ux/turbine-type-modal/component.tsx b/front/src/components/ux/turbine-type-modal/component.tsx new file mode 100644 index 0000000..2d246d6 --- /dev/null +++ b/front/src/components/ux/turbine-type-modal/component.tsx @@ -0,0 +1,79 @@ +import { + Button, + Heading, + NumberInput, + Overlay, + TextInput, +} from '@components/ui'; +import { Controller, useForm } from '@utils/form'; +import React, { useEffect, useState } from 'react'; +import { createTurbineTypes, editTurbineTypes } from 'src/api/wind'; + +import styles from './styles.module.scss'; +import { TurbineTypeFormValues, TurbineTypeModalProps } from './types'; +import { turbineTypeToFormValues } from './utils'; + +export function TurbineTypeModal({ + turbineType, + open, + onClose, + onSuccess, +}: TurbineTypeModalProps) { + const [pending, setPending] = useState(false); + const isEdit = turbineType !== null; + + const { register, control, getValues, reset } = + useForm({ + initialValues: {}, + }); + + useEffect(() => { + reset(turbineType ? turbineTypeToFormValues(turbineType) : {}); + }, [turbineType]); + + const handleSubmit = async (event: React.FormEvent) => { + event.preventDefault(); + setPending(true); + if (isEdit) { + await editTurbineTypes(getValues(), turbineType.id); + } else { + await createTurbineTypes(getValues()); + } + setPending(false); + onClose(); + onSuccess(); + }; + + return ( + +
+ + {isEdit ? 'Edit' : 'Create new'} + +
+ + ( + + )} + /> + ( + + )} + /> +
+
+ + +
+
+
+ ); +} diff --git a/front/src/components/ux/turbine-type-modal/index.ts b/front/src/components/ux/turbine-type-modal/index.ts new file mode 100644 index 0000000..0bfef12 --- /dev/null +++ b/front/src/components/ux/turbine-type-modal/index.ts @@ -0,0 +1,2 @@ +export * from './component'; +export { type TurbineTypeFormValues } from './types'; diff --git a/front/src/components/ux/sign-in-form/styles.module.scss b/front/src/components/ux/turbine-type-modal/styles.module.scss similarity index 69% rename from front/src/components/ux/sign-in-form/styles.module.scss rename to front/src/components/ux/turbine-type-modal/styles.module.scss index c7724d2..6c5db31 100644 --- a/front/src/components/ux/sign-in-form/styles.module.scss +++ b/front/src/components/ux/turbine-type-modal/styles.module.scss @@ -5,6 +5,8 @@ background-color: var(--clr-layer-200); box-shadow: 0px 1px 2px var(--clr-shadow-100); gap: 40px; + grid-area: form; + grid-template-rows: auto 1fr auto; & > * { width: 100%; @@ -19,3 +21,9 @@ display: grid; gap: 20px; } + +.buttonBox { + display: flex; + justify-content: end; + gap: 10px; +} diff --git a/front/src/components/ux/turbine-type-modal/types.ts b/front/src/components/ux/turbine-type-modal/types.ts new file mode 100644 index 0000000..82e4d6f --- /dev/null +++ b/front/src/components/ux/turbine-type-modal/types.ts @@ -0,0 +1,14 @@ +import { TurbineType } from 'src/api/wind'; + +export type TurbineTypeFormValues = { + name: string; + height: string; + bladeLength: string; +}; + +export type TurbineTypeModalProps = { + turbineType: TurbineType; + open: boolean; + onClose: () => void; + onSuccess: () => void; +}; diff --git a/front/src/components/ux/turbine-type-modal/utils.ts b/front/src/components/ux/turbine-type-modal/utils.ts new file mode 100644 index 0000000..4a2e0a1 --- /dev/null +++ b/front/src/components/ux/turbine-type-modal/utils.ts @@ -0,0 +1,13 @@ +import { TurbineType } from 'src/api/wind'; + +import { TurbineTypeFormValues } from './types'; + +export const turbineTypeToFormValues = ( + turbineType: TurbineType, +): TurbineTypeFormValues => { + return { + name: turbineType.name, + height: String(turbineType.height), + bladeLength: String(turbineType.bladeLength), + }; +}; diff --git a/front/src/utils/route/constants.ts b/front/src/utils/route/constants.ts new file mode 100644 index 0000000..7922590 --- /dev/null +++ b/front/src/utils/route/constants.ts @@ -0,0 +1,15 @@ +import { arrayToObject } from '@utils/array'; + +import { AppRoute, AppRouteName } from './types'; + +export const ROUTES: Record = { + turbineTypes: { path: 'turbine-types', title: 'Turbine types' }, + parks: { path: 'parks', title: 'Parks' }, +}; + +export const routeArray = Object.values(ROUTES); + +export const routeMap = arrayToObject( + Object.keys(ROUTES) as AppRouteName[], + (route) => ROUTES[route]?.path, +); diff --git a/front/src/utils/route/index.tsx b/front/src/utils/route/index.tsx new file mode 100644 index 0000000..2c96dd7 --- /dev/null +++ b/front/src/utils/route/index.tsx @@ -0,0 +1,3 @@ +export { ROUTES } from './constants'; +export { type AppRoute } from './types'; +export { useRoute } from './use-route'; diff --git a/front/src/utils/route/types.ts b/front/src/utils/route/types.ts new file mode 100644 index 0000000..9f3c9cb --- /dev/null +++ b/front/src/utils/route/types.ts @@ -0,0 +1,6 @@ +export type AppRouteName = 'turbineTypes' | 'parks'; + +export type AppRoute = { + path: string; + title: string; +}; diff --git a/front/src/utils/route/use-route.ts b/front/src/utils/route/use-route.ts new file mode 100644 index 0000000..f895be0 --- /dev/null +++ b/front/src/utils/route/use-route.ts @@ -0,0 +1,9 @@ +import { matchRoutes, useLocation } from 'react-router-dom'; + +import { routeArray, routeMap, ROUTES } from './constants'; + +export const useRoute = () => { + const location = useLocation(); + const match = matchRoutes(routeArray, location); + return match ? ROUTES[routeMap[match[0].route.path]] : null; +};