NestJS backend rewrite; migrate client to FluentUI V9

This commit is contained in:
2025-09-18 15:48:08 +09:00
parent 32ff36a12c
commit 34529cea68
62 changed files with 5642 additions and 3679 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3308
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -13,31 +13,20 @@
"dependencies": { "dependencies": {
"-": "^0.0.1", "-": "^0.0.1",
"@dnd-kit/core": "^6.3.1", "@dnd-kit/core": "^6.3.1",
"@fluentui/react-components": "^9.69.0",
"@fluentui/react-icons": "^2.0.309",
"@fontsource/inter": "^5.0.19", "@fontsource/inter": "^5.0.19",
"@fontsource/open-sans": "^5.0.28", "@fontsource/open-sans": "^5.0.28",
"@hello-pangea/dnd": "^17.0.0", "@hello-pangea/dnd": "^17.0.0",
"@js-preview/docx": "^1.6.2", "@js-preview/docx": "^1.6.2",
"@js-preview/excel": "^1.7.8", "@js-preview/excel": "^1.7.8",
"@js-preview/pdf": "^2.0.2", "@js-preview/pdf": "^2.0.2",
"@mantine/carousel": "^7.13.0",
"@mantine/charts": "^7.13.0",
"@mantine/code-highlight": "^7.13.0",
"@mantine/core": "^7.13.0", "@mantine/core": "^7.13.0",
"@mantine/dates": "^7.13.0", "@mantine/dates": "^7.13.0",
"@mantine/dropzone": "^7.13.0", "@mantine/dropzone": "^7.13.0",
"@mantine/form": "^7.13.0",
"@mantine/hooks": "^7.13.0",
"@mantine/modals": "^7.13.0",
"@mantine/notifications": "^7.13.0",
"@mantine/nprogress": "^7.13.0", "@mantine/nprogress": "^7.13.0",
"@mantine/spotlight": "^7.13.0",
"@mantine/tiptap": "^7.13.0",
"@tabler/icons-react": "^3.17.0", "@tabler/icons-react": "^3.17.0",
"@tanstack/react-table": "^8.20.5", "@tanstack/react-table": "^8.20.5",
"@techstark/opencv-js": "^4.10.0-release.1",
"@tiptap/extension-link": "^2.7.3",
"@tiptap/react": "^2.7.3",
"@tiptap/starter-kit": "^2.7.3",
"@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.5.0", "@types/ol-ext": "npm:@siedlerchr/types-ol-ext@^3.5.0",
"@uidotdev/usehooks": "^2.4.1", "@uidotdev/usehooks": "^2.4.1",
"ag-grid-react": "^33.3.2", "ag-grid-react": "^33.3.2",
@ -46,9 +35,7 @@
"buffer": "^6.0.3", "buffer": "^6.0.3",
"dayjs": "^1.11.13", "dayjs": "^1.11.13",
"docx-templates": "^4.13.0", "docx-templates": "^4.13.0",
"embla-carousel-react": "^8.3.0",
"file-type": "^19.0.0", "file-type": "^19.0.0",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2", "jspdf": "^2.5.2",
"ol": "^10.0.0", "ol": "^10.0.0",
"ol-ext": "^4.0.23", "ol-ext": "^4.0.23",
@ -57,7 +44,6 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"react-hook-form": "^7.52.0", "react-hook-form": "^7.52.0",
"react-router-dom": "^6.23.1", "react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"swr": "^2.2.5", "swr": "^2.2.5",
"uuid": "^11.0.3", "uuid": "^11.0.3",
"zustand": "^4.5.2" "zustand": "^4.5.2"

View File

@ -4,8 +4,8 @@ import MainLayout from "./layouts/MainLayout"
import { initAuth, useAuthStore } from "./store/auth" import { initAuth, useAuthStore } from "./store/auth"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import DashboardLayout from "./layouts/DashboardLayout" import DashboardLayout from "./layouts/DashboardLayout"
import { Box, Loader } from "@mantine/core"
import { pages } from "./constants/app" import { pages } from "./constants/app"
import { Spinner } from "@fluentui/react-components"
function App() { function App() {
const auth = useAuthStore() const auth = useAuthStore()
@ -24,11 +24,14 @@ function App() {
if (isLoading) { if (isLoading) {
return ( return (
<Loader /> <Spinner />
) )
} else { } else {
return ( return (
<Box w='100%' h='100vh'> <div style={{
width: '100%',
height: '100vh'
}}>
<Router> <Router>
<Routes> <Routes>
<Route element={<MainLayout />}> <Route element={<MainLayout />}>
@ -45,7 +48,7 @@ function App() {
</Route> </Route>
</Routes> </Routes>
</Router> </Router>
</Box> </div>
) )
} }
} }

View File

@ -1,4 +1,4 @@
import { Divider, Flex, Text } from '@mantine/core'; import { Divider, Text } from '@fluentui/react-components';
import { PropsWithChildren } from 'react' import { PropsWithChildren } from 'react'
interface CardInfoProps extends PropsWithChildren { interface CardInfoProps extends PropsWithChildren {
@ -10,14 +10,14 @@ export default function CardInfo({
label label
}: CardInfoProps) { }: CardInfoProps) {
return ( return (
<Flex direction='column' gap='sm' p='sm'> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', padding: '1rem' }}>
<Text fw={600}> <Text weight='semibold'>
{label} {label}
</Text> </Text>
<Divider /> <Divider />
{children} {children}
</Flex> </div>
) )
} }

View File

@ -1,26 +0,0 @@
import { Chip } from '@mantine/core';
import { ReactElement } from 'react'
interface CardInfoChipProps {
status: boolean;
label: string;
iconOn: ReactElement
iconOff: ReactElement
}
export default function CardInfoChip({
status,
label,
iconOn,
iconOff
}: CardInfoChipProps) {
return (
<Chip
icon={status ? iconOn : iconOff}
color={status ? "success" : "error"}
variant='outline'
>
{label}
</Chip>
)
}

View File

@ -1,4 +1,5 @@
import { Flex, Text } from '@mantine/core'; import { Text } from "@fluentui/react-components";
interface CardInfoLabelProps { interface CardInfoLabelProps {
label: string; label: string;
value: string | number; value: string | number;
@ -9,14 +10,14 @@ export default function CardInfoLabel({
value value
}: CardInfoLabelProps) { }: CardInfoLabelProps) {
return ( return (
<Flex justify='space-between' align='center'> <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
<Text> <Text>
{label} {label}
</Text> </Text>
<Text fw={600}> <Text weight='semibold'>
{value} {value}
</Text> </Text>
</Flex> </div>
) )
} }

View File

@ -1,205 +1,195 @@
import { Badge, Button, Flex, Input, Modal, ScrollAreaAutosize, Select, Stack, Table, TextInput } from '@mantine/core'; import { useState } from 'react';
import { Cell, ColumnDef, flexRender, getCoreRowModel, useReactTable } from '@tanstack/react-table';
import { useEffect, useMemo, useState } from 'react';
import styles from './CustomTable.module.scss'
import { useRoles } from '../hooks/swrHooks';
import { IRole } from '../interfaces/role';
import { IconPlus } from '@tabler/icons-react'; import { IconPlus } from '@tabler/icons-react';
import { CreateField } from '../interfaces/create'; import { CreateField } from '../interfaces/create';
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import FormFields from './FormFields'; import FormFields from './FormFields';
import { useDisclosure } from '@mantine/hooks'; import { Badge, Button, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Dialog, DialogSurface, DialogTitle, DialogTrigger, Input, Select, TableCellLayout, TableColumnDefinition } from '@fluentui/react-components';
import { IColumn } from '../interfaces/DataGrid/columns';
type CustomTableProps<T> = { type CustomTableProps<T> = {
data: T[]; data: (T & { id: number })[];
columns: ColumnDef<T>[]; columns: IColumn[];
createFields?: CreateField[]; createFields?: CreateField[];
submitHandler?: (data: T) => Promise<AxiosResponse> submitHandler?: (data: T) => Promise<AxiosResponse>
onEditCell?: (rowId: number, columnId: string, value: any) => any
} }
const CustomTable = <T extends object>({ const CustomTable = <T extends object>({
data: initialData, data: initialData,
columns, columns,
createFields, createFields,
submitHandler submitHandler,
}: CustomTableProps<T>) => { }: CustomTableProps<T>) => {
const [data, setData] = useState<T[]>(initialData); const [data, setData] = useState<(T & { id: number })[]>(initialData);
const [searchText, setSearchText] = useState(''); const [searchText, setSearchText] = useState('');
const [editingCell, setEditingCell] = useState<{ rowIndex: string | number | null, columnId: string | number | null }>({ rowIndex: null, columnId: null });
const tableColumns = useMemo(() => columns, [columns]); const [editingCell, setEditingCell] = useState<{
rowId: number | null
columnId: string | null
}>({ rowId: null, columnId: null })
// Function to handle cell edit const handleEditCell = (rowId: number, columnId: string, value: any) => {
const handleEditCell = ( setData((prev) =>
rowIndex: number, prev.map((row) =>
columnId: keyof T, row.id === rowId ? { ...row, [columnId]: value } : row
value: T[keyof T] )
) => {
const updatedData = [...data];
updatedData[rowIndex][columnId] = value;
setData(updatedData);
//setEditingCell({ rowIndex: null, columnId: null });
};
const filteredData = useMemo(() => {
if (!searchText) return data;
return data.filter((row) =>
Object.values(row).some((value) =>
value?.toString().toLowerCase().includes(searchText.toLowerCase())
) )
);
}, [data, searchText])
const table = useReactTable({
data: filteredData,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
});
const [opened, { open, close }] = useDisclosure(false);
return (
<Stack h='100%'>
{createFields && submitHandler &&
<Modal opened={opened} onClose={close} title="Добавление объекта" centered>
<FormFields
fields={createFields}
submitHandler={submitHandler}
/>
</Modal>
} }
<Flex w='100%' gap='sm'> const columnDefinitions: TableColumnDefinition<any>[] = columns.map(column => (
<TextInput createTableColumn<any>({
columnId: column.name,
renderHeaderCell: () => column.header,
renderCell: (item) => {
const isEditing = editingCell.rowId === item.id && editingCell.columnId === column.name;
switch (column.type) {
case 'number':
return (
<TableCellLayout
truncate
onDoubleClick={() =>
setEditingCell({ rowId: item.id, columnId: column.name })
}
>
{isEditing ? (
<Input
value={item[column.name]}
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
autoFocus
/>
) : (
item[column.name]
)}
</TableCellLayout>
)
case 'string':
return (
<TableCellLayout
truncate
onDoubleClick={() =>
setEditingCell({ rowId: item.id, columnId: column.name })
}
>
{isEditing ? (
<Input
value={item[column.name]}
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
autoFocus
/>
) : (
item[column.name]
)}
</TableCellLayout>
)
case 'boolean':
return (
<TableCellLayout onDoubleClick={() =>
setEditingCell({ rowId: item.id, columnId: column.name })
}>
{isEditing ? (
<Select
value={item[column.name]}
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
onBlur={() => setEditingCell({ rowId: null, columnId: null })}>
<option value='true'>Активен</option>
<option value='false'>Неактивен</option>
</Select>
) : (
<Badge color={JSON.parse(item[column.name]) === true ? 'success' : 'danger'}>
{JSON.parse(item[column.name]) === true ? 'Активен' : 'Неактивен'}
</Badge>
)}
</TableCellLayout>
)
case 'dictionary':
return (
<TableCellLayout
onDoubleClick={() =>
setEditingCell({ rowId: item.id, columnId: column.name })
}
>
{isEditing ? (
<Input
value={item[column.name]}
onChange={(_, d) => handleEditCell?.(item.id, column.name, d.value)}
onBlur={() => setEditingCell({ rowId: null, columnId: null })}
autoFocus
/>
) : (
item[column.name]
)}
</TableCellLayout>
)
}
},
})
))
return (
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
<div style={{
display: 'flex',
gap: '1rem'
}}>
<Input
placeholder="Поиск" placeholder="Поиск"
value={searchText} value={searchText}
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
w='100%'
/> />
{createFields && submitHandler && {createFields && submitHandler &&
<Dialog>
<DialogTrigger>
<Button <Button
leftSection={<IconPlus />} appearance='primary'
onClick={open} icon={<IconPlus />}
style={{ flexShrink: 0 }} style={{ flexShrink: 0 }}
> >
Добавить Добавить
</Button> </Button>
} </DialogTrigger>
</Flex>
<ScrollAreaAutosize offsetScrollbars style={{ borderRadius: '4px' }}> <DialogSurface>
<Table stickyHeader striped withColumnBorders highlightOnHover className={styles.table}> <DialogTitle>Добавление объекта</DialogTitle>
<Table.Thead className={styles.thead}> <FormFields
{table.getHeaderGroups().map(headerGroup => ( fields={createFields}
<Table.Tr key={headerGroup.id} className={styles.tr}> submitHandler={submitHandler}
{headerGroup.headers.map((header) => (
<Table.Th key={header.id} className={styles.th} w={header.getSize()}>
{flexRender(header.column.columnDef.header, header.getContext())}
<div
className={styles.resize_handler}
onMouseDown={header.getResizeHandler()} //for desktop
onTouchStart={header.getResizeHandler()}
>
</div>
</Table.Th>
))}
</Table.Tr>
))}
</Table.Thead>
<Table.Tbody className={styles.tbody}>
{table.getRowModel().rows.map((row, rowIndex) => (
<Table.Tr key={row.id} className={styles.tr}>
{row.getVisibleCells().map(cell => {
const isEditing = editingCell.rowIndex === rowIndex && editingCell.columnId === cell.column.id;
return (
<Table.Td
key={cell.id}
onDoubleClick={() => setEditingCell({ rowIndex, columnId: cell.column.id })}
style={{ width: cell.column.getSize() }}
className={styles.td}
>
{isEditing ? (
<Input
type='text'
value={(data[rowIndex][cell.column.id as keyof T] as string)}
onChange={(e) => handleEditCell(rowIndex, (cell.column.id as keyof T), e.target.value as T[keyof T])}
onBlur={() => setEditingCell({ rowIndex: null, columnId: null })}
autoFocus
/> />
) : ( </DialogSurface>
<CellDisplay cell={cell} /> </Dialog>
}
</div>
<DataGrid
items={data}
columns={columnDefinitions}
resizableColumns
focusMode="cell"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)} )}
</Table.Td> </DataGridRow>
); </DataGridHeader>
})} <DataGridBody>
</Table.Tr> {({ item, rowId }) => (
))} <DataGridRow key={rowId}>
</Table.Tbody> {({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
</Table> </DataGridRow>
</ScrollAreaAutosize> )}
</Stack> </DataGridBody>
</DataGrid>
</div>
); );
}; };
type CellDisplayProps<T> = {
cell: Cell<T, unknown>;
}
const CellDisplay = <T extends object>({
cell
}: CellDisplayProps<T>) => {
const { roles } = useRoles()
const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>()
useEffect(() => {
if (Array.isArray(roles)) {
setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() })))
}
}, [roles])
switch (cell.column.id) {
case 'activity':
return (
cell.getValue() ? (
<Badge fullWidth variant="light">
Активен
</Badge>
) : (
<Badge color="gray" fullWidth variant="light">
Отключен
</Badge>
)
)
case 'is_active':
return (
cell.getValue() ? (
<Badge fullWidth variant="light">
Активен
</Badge>
) : (
<Badge color="gray" fullWidth variant="light">
Отключен
</Badge>
)
)
case 'role_id':
return (
<Select
data={roleOptions}
value={Number(cell.getValue()).toString()}
variant="unstyled"
allowDeselect={false}
/>
)
default:
return (
flexRender(cell.column.columnDef.cell, cell.getContext())
)
}
}
export default CustomTable; export default CustomTable;

View File

@ -4,23 +4,14 @@ import React, { useEffect, useState } from 'react'
import DocumentService from '../services/DocumentService' import DocumentService from '../services/DocumentService'
import { mutate } from 'swr' import { mutate } from 'swr'
import FileViewer from './modals/FileViewer' import FileViewer from './modals/FileViewer'
import { ActionIcon, Anchor, Breadcrumbs, Button, Divider, FileButton, Flex, Loader, MantineStyleProp, RingProgress, ScrollAreaAutosize, Stack, Table, Text } from '@mantine/core' import { IconCancel, IconDownload, IconFileUpload, IconX } from '@tabler/icons-react'
import { IconCancel, IconDownload, IconFile, IconFileFilled, IconFilePlus, IconFileUpload, IconFolderFilled, IconX } from '@tabler/icons-react' import { Breadcrumb, BreadcrumbButton, BreadcrumbDivider, BreadcrumbItem, Button, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Divider, Field, ProgressBar, Spinner, TableCellLayout } from '@fluentui/react-components'
import { DocumentAdd20Regular, DocumentColor, DocumentRegular, DocumentTextColor, FolderRegular, ImageColor, TableColor } from '@fluentui/react-icons'
interface DocumentProps { interface DocumentProps {
doc: IDocument; doc: IDocument;
} }
const FileItemStyle: MantineStyleProp = {
cursor: 'pointer',
display: 'flex',
width: '100%',
flexDirection: 'row',
gap: '8px',
alignItems: 'center',
padding: '8px'
}
const handleSave = async (file: Blob, filename: string) => { const handleSave = async (file: Blob, filename: string) => {
const link = document.createElement('a') const link = document.createElement('a')
link.href = window.URL.createObjectURL(file) link.href = window.URL.createObjectURL(file)
@ -30,6 +21,27 @@ const handleSave = async (file: Blob, filename: string) => {
window.URL.revokeObjectURL(link.href) window.URL.revokeObjectURL(link.href)
} }
function getFileExtension(filename: string): string {
return filename.split('.').pop()?.toLowerCase() || '';
}
function handleDocFormatIcon(docName: string) {
const ext = getFileExtension(docName);
switch (ext) {
case 'docx':
return <DocumentTextColor />
case 'pdf':
return <DocumentTextColor color='red' />
case 'xlsx':
return <TableColor />
case 'jpg':
return <ImageColor />
default:
return <DocumentRegular />
}
}
function ItemDocument({ doc }: DocumentProps) { function ItemDocument({ doc }: DocumentProps) {
const [shouldFetch, setShouldFetch] = useState(false) const [shouldFetch, setShouldFetch] = useState(false)
@ -45,22 +57,16 @@ function ItemDocument({ doc }: DocumentProps) {
}, [shouldFetch, file, doc.name]) }, [shouldFetch, file, doc.name])
return ( return (
<Flex> <Button icon={isLoading ?
<ActionIcon <Spinner size='tiny' />
onClick={(e) => { :
<IconDownload />
} appearance='subtle' onClick={(e) => {
e.stopPropagation() e.stopPropagation()
if (!isLoading) { if (!isLoading) {
setShouldFetch(true) setShouldFetch(true)
} }
}} }} />
variant='subtle'>
{isLoading ?
<Loader size='sm' />
:
<IconDownload />
}
</ActionIcon>
</Flex>
) )
} }
@ -134,14 +140,32 @@ export default function FolderViewer() {
} }
} }
const fileInputRef = React.useRef<HTMLInputElement | null>(null);
const handleClick = () => {
fileInputRef.current?.click();
};
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
if (e.target.files) {
// do what Mantine's onChange did
console.log("Selected files:", Array.from(e.target.files));
handleFileInput(Array.from(e.target.files))
}
};
if (foldersLoading || documentsLoading) { if (foldersLoading || documentsLoading) {
return ( return (
<Loader /> <Spinner />
) )
} }
return ( return (
<ScrollAreaAutosize w={'100%'} h={'100%'} p={'sm'}> <div style={{
width: '100%',
height: '100%',
padding: '1rem',
}}>
{fileViewerModal && {fileViewerModal &&
<FileViewer <FileViewer
open={fileViewerModal} open={fileViewerModal}
@ -152,51 +176,74 @@ export default function FolderViewer() {
/> />
} }
<div style={{
<Stack> display: 'flex',
<Breadcrumbs> flexDirection: 'column',
<Anchor height: '100%',
onClick={() => { width: '100%',
gap: '1rem'
}}>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbButton onClick={() => {
setCurrentFolder(null) setCurrentFolder(null)
setBreadcrumbs([]) setBreadcrumbs([])
}} }}>Главная</BreadcrumbButton>
> </BreadcrumbItem>
Главная
</Anchor>
{breadcrumbs.map((breadcrumb, index) => ( {breadcrumbs.map((breadcrumb, index) => (
<Anchor <>
key={breadcrumb.id} <BreadcrumbDivider />
onClick={() => handleBreadcrumbClick(index)} <BreadcrumbItem key={breadcrumb.id}>
> <BreadcrumbButton icon={<FolderRegular />} onClick={() => {
{breadcrumb.name} handleBreadcrumbClick(index)
</Anchor> }}>{breadcrumb.name}</BreadcrumbButton>
</BreadcrumbItem>
</>
))} ))}
</Breadcrumbs> </Breadcrumb>
{currentFolder && {currentFolder &&
<Flex direction='column' gap='sm'> <div style={{
<Flex direction='column' gap='sm' p='sm' style={{ display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem',
padding: '1rem',
border: filesToUpload.length > 0 ? '1px dashed gray' : 'none', border: filesToUpload.length > 0 ? '1px dashed gray' : 'none',
borderRadius: '8px', borderRadius: '8px',
}}> }}>
<Flex gap='sm'> <div style={{ display: 'flex', gap: '1rem' }}>
<FileButton multiple onChange={handleFileInput}> <input
{(props) => <Button variant='filled' leftSection={isUploading ? <Loader /> : <IconFilePlus />} {...props}>Добавить</Button>} type="file"
</FileButton> multiple
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<Button appearance="primary" icon={<DocumentAdd20Regular />} onClick={handleClick}>
Добавить
</Button>
{filesToUpload.length > 0 && {filesToUpload.length > 0 &&
<> <>
<Button <Button
variant='filled' appearance='primary'
leftSection={isUploading ? <RingProgress sections={[{ value: uploadProgress, color: 'blue' }]} /> : <IconFileUpload />} icon={<IconFileUpload />}
onClick={uploadFiles} onClick={uploadFiles}
disabled={isUploading}
> >
Загрузить все Загрузить все
</Button> </Button>
<Button <Button
variant='outline' appearance='outline'
leftSection={<IconCancel />} icon={<IconCancel />}
onClick={() => { onClick={() => {
setFilesToUpload([]) setFilesToUpload([])
}} }}
@ -205,88 +252,114 @@ export default function FolderViewer() {
</Button> </Button>
</> </>
} }
</Flex> </div>
{isUploading &&
<Field validationMessage={"Загрузка файлов..."} validationState='none'>
<ProgressBar value={uploadProgress} />
</Field>
}
<Divider /> <Divider />
{filesToUpload.length > 0 && {filesToUpload.length > 0 &&
<Flex direction='column'> <div style={{
display: 'flex',
flexDirection: 'column'
}}>
{filesToUpload.map((file, index) => ( {filesToUpload.map((file, index) => (
<Flex key={index} p='8px'> <div style={{
<Flex gap='sm' direction='row' align='center'> display: 'flex',
<IconFile /> }} key={index}>
<Text>{file.name}</Text> <Button appearance='transparent' icon={<DocumentColor />}>
</Flex> {file.name}
</Button>
<ActionIcon onClick={() => { <Button style={{ marginLeft: 'auto' }} appearance='subtle' icon={<IconX />} onClick={() => {
setFilesToUpload(prev => { setFilesToUpload(prev => {
return prev.filter((_, i) => i != index) return prev.filter((_, i) => i != index)
}) })
}} ml='auto' variant='subtle'> }} />
<IconX /> </div>
</ActionIcon>
</Flex>
))} ))}
</Flex> </div>
} }
</Flex> </div>
</Flex> </div>
} }
<Table <div style={{
width: '100%',
overflow: 'auto'
}}>
<DataGrid
onDragOver={handleDragOver} onDragOver={handleDragOver}
onDragLeave={handleDragLeave} onDragLeave={handleDragLeave}
onDrop={handleDrop} onDrop={handleDrop}
bg={dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'} style={{ backgroundColor: dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit' }}
highlightOnHover> items={currentFolder
<Table.Thead> ? (documents ?? []).map((doc: IDocument) => ({ kind: "doc", data: doc }))
<Table.Tr> : (folders ?? []).map((folder: IDocumentFolder) => ({ kind: "folder", data: folder }))}
<Table.Th>Название</Table.Th> columns={[
<Table.Th p={0}>Дата создания</Table.Th> createTableColumn({
<Table.Th p={0}></Table.Th> columnId: "name",
</Table.Tr> renderHeaderCell: () => "Название",
</Table.Thead> renderCell: (item) => (
<TableCellLayout truncate media={item.kind === "doc" ? handleDocFormatIcon(item.data.name) : <FolderRegular />}>
<Table.Tbody> {item.data.name}
{currentFolder ? ( </TableCellLayout>
documents?.map((doc: IDocument, index: number) => ( ),
<Table.Tr key={doc.id} onClick={() => handleDocumentClick(index)} style={{ cursor: 'pointer' }}> }),
<Table.Td p={0}> createTableColumn({
<Flex style={FileItemStyle}> columnId: "date",
<IconFileFilled /> renderHeaderCell: () => "Дата создания",
{doc.name} renderCell: (item) =>
</Flex> new Date(item.data.create_date).toLocaleDateString(),
</Table.Td> }),
<Table.Td p={0}> createTableColumn({
{new Date(doc.create_date).toLocaleDateString()} columnId: "actions",
</Table.Td> renderHeaderCell: () => "",
<Table.Td p={0}> renderCell: (item) => {
<ItemDocument if (item.kind === "doc") {
doc={doc} // replace with your <ItemDocument doc={doc} />
/> return <ItemDocument doc={item.data} />;
</Table.Td> }
</Table.Tr> return null;
)) },
) : ( }),
folders?.map((folder: IDocumentFolder) => ( ]}
<Table.Tr key={folder.id} onClick={() => handleFolderClick(folder)} style={{ cursor: 'pointer' }}> focusMode="cell"
<Table.Td p={0}> resizableColumns
<Flex style={FileItemStyle}> getRowId={(item) => item.data.id}
<IconFolderFilled /> >
{folder.name} <DataGridHeader>
</Flex> <DataGridRow>
</Table.Td> {({ renderHeaderCell }) => (
<Table.Td p={0} align='left'> <DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
{new Date(folder.create_date).toLocaleDateString()}
</Table.Td>
<Table.Td p={0}>
</Table.Td>
</Table.Tr>
))
)} )}
</Table.Tbody> </DataGridRow>
</Table> </DataGridHeader>
</Stack> <DataGridBody>
</ScrollAreaAutosize> {({ item, rowId }: { item: { kind: string, data: any }, rowId: any }) => (
<DataGridRow
key={rowId}
style={{ cursor: "pointer" }}
onClick={() => {
if (item.kind === "doc") {
const index = documents?.findIndex((d: any) => d.id === item.data.id) ?? -1;
handleDocumentClick(index);
} else {
handleFolderClick(item.data as IDocumentFolder);
}
}}
>
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
</div>
</div>
</div>
) )
} }

View File

@ -1,7 +1,7 @@
import { SubmitHandler, useForm } from 'react-hook-form' import { SubmitHandler, useForm } from 'react-hook-form'
import { CreateField } from '../interfaces/create' import { CreateField } from '../interfaces/create'
import { AxiosResponse } from 'axios'; import { AxiosResponse } from 'axios';
import { Button, Loader, Stack, Text, TextInput } from '@mantine/core'; import { Button, Field, Input, Spinner, Text } from '@fluentui/react-components';
interface Props { interface Props {
title?: string; title?: string;
@ -51,20 +51,17 @@ function FormFields({
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack gap='sm' w='100%'> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
{title.length > 0 && {title.length > 0 &&
<Text size="xl" fw={500}> <Text size={500} weight='semibold'>
{title} {title}
</Text> </Text>
} }
{fields.map((field: CreateField) => { {fields.map((field: CreateField) => {
return ( return (
<TextInput <Field key={field.key} validationState={errors[field.key]?.message ? 'error' : 'none'} label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}>
key={field.key} <Input type={field.inputType ? field.inputType : 'text'}
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
//placeholder="Your name"
type={field.inputType ? field.inputType : 'text'}
{...register(field.key, { {...register(field.key, {
required: field.required ? `${field.headerName} обязателен` : false, required: field.required ? `${field.headerName} обязателен` : false,
validate: (val: string | boolean) => { validate: (val: string | boolean) => {
@ -75,18 +72,16 @@ function FormFields({
} }
}, },
})} })}
radius="md"
required={field.required || false} required={field.required || false}
error={errors[field.key]?.message}
errorProps={errors[field.key]}
/> />
</Field>
) )
})} })}
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type='submit'> <Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type='submit'>
{isSubmitting ? <Loader size={16} /> : submitButtonText} {isSubmitting ? <Spinner size='extra-small' /> : submitButtonText}
</Button> </Button>
</Stack> </div>
</form> </form>
) )
} }

View File

@ -1,40 +1,46 @@
import { IServer } from '../interfaces/servers' import { IServer } from '../interfaces/servers'
import { useServerIps } from '../hooks/swrHooks' import { useServerIps } from '../hooks/swrHooks'
import { Flex, Table } from '@mantine/core' import CustomTable from './CustomTable'
function ServerData({ id }: IServer) { function ServerData({ id }: IServer) {
const { serverIps } = useServerIps(id, 0, 10) const { serverIps } = useServerIps(id, 0, 10)
const serverIpsColumns = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
{ field: 'ip', headerName: 'IP', type: 'string' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
]
return ( return (
<Flex direction='column' p='sm'> <div style={{ display: 'flex', flexDirection: 'column', padding: '1rem' }}>
{serverIps && {serverIps &&
<Table highlightOnHover> <CustomTable data={serverIps} columns={[
<Table.Thead> {
<Table.Tr> name: 'id',
{serverIpsColumns.map(column => ( header: 'ID',
<Table.Th key={column.field}>{column.headerName}</Table.Th> type: "number"
))} },
</Table.Tr> {
</Table.Thead> name: 'server_id',
<Table.Tbody> header: 'Server ID',
<Table.Tr> type: "number"
{serverIpsColumns.map(column => ( },
<Table.Td key={column.field}>{serverIps ? serverIps[column.field] : ''}</Table.Td> {
))} name: 'name',
</Table.Tr> header: 'Название',
</Table.Tbody> type: "string"
</Table> },
{
name: 'is_actual',
header: 'Действителен',
type: "boolean"
},
{
name: 'ip',
header: 'IP',
type: "string"
},
{
name: 'servername',
header: 'Название сервера',
type: "string"
} }
</Flex> ]} />}
</div>
) )
} }

View File

@ -1,7 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { useHardwares, useServers } from '../hooks/swrHooks' import { useHardwares, useServers } from '../hooks/swrHooks'
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
import { IServer } from '../interfaces/servers' import { IServer } from '../interfaces/servers'
import { Combobox, Option, Spinner } from '@fluentui/react-components'
import CustomTable from './CustomTable'
export default function ServerHardware() { export default function ServerHardware() {
const [selectedOption, setSelectedOption] = useState<number | undefined>(undefined) const [selectedOption, setSelectedOption] = useState<number | undefined>(undefined)
@ -9,64 +10,40 @@ export default function ServerHardware() {
const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption, 0, 10) const { hardwares, isLoading: serversLoading } = useHardwares(selectedOption, 0, 10)
const hardwareColumns = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
{ field: 'os_info', headerName: 'ОС', type: 'string' },
{ field: 'ram', headerName: 'ОЗУ', type: 'string' },
{ field: 'processor', headerName: 'Проц.', type: 'string' },
{ field: 'storages_count', headerName: 'Кол-во хранилищ', type: 'number' },
]
return ( return (
<> <>
<form> <form>
<Autocomplete <Combobox
placeholder="Сервер" placeholder="Сервер"
flex={'1'} style={{ flex: 1 }}
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []} selectedOptions={selectedOption ? [selectedOption.toString()] : []}
onSelect={(e) => console.log(e.currentTarget.value)} onOptionSelect={(_, data) => {
//onChange={(value) => setSearch(value)} if (data.optionValue) {
onOptionSubmit={(value) => setSelectedOption(Number(value))} setSelectedOption(Number(data.optionValue));
rightSection={
//search !== '' &&
(
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
//setSearch('')
setSelectedOption(undefined)
}}
aria-label="Clear value"
/>
)
} }
//value={search} }}
/> >
{servers?.map((item: IServer) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
</Option>
))}
</Combobox>
</form> </form>
{serversLoading ? {serversLoading ?
<Loader /> <Spinner />
: :
<Table highlightOnHover> <CustomTable data={hardwares} columns={[
<Table.Thead> { name: 'id', header: 'ID', type: 'number' },
<Table.Tr> { name: 'name', header: 'Название', type: 'string' },
{hardwareColumns.map(column => ( { name: 'server_id', header: 'Server ID', type: 'number' },
<Table.Th key={column.field}>{column.headerName}</Table.Th> { name: 'servername', header: 'Название сервера', type: 'string' },
))} { name: 'os_info', header: 'ОС', type: 'string' },
</Table.Tr> { name: 'ram', header: 'ОЗУ', type: 'string' },
</Table.Thead> { name: 'processor', header: 'Проц.', type: 'string' },
<Table.Tbody> { name: 'storages_count', header: 'Кол-во хранилищ', type: 'number' },
<Table.Tr> ]} />
{hardwareColumns.map(column => (
<Table.Td key={column.field}>{hardwares ? hardwares[column.field] : ''}</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
} }
</> </>
) )

View File

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useServerIps, useServers } from '../hooks/swrHooks' import { useServerIps, useServers } from '../hooks/swrHooks'
import { Autocomplete, CloseButton, Loader, Table } from '@mantine/core'
import { IServer } from '../interfaces/servers' import { IServer } from '../interfaces/servers'
import { Combobox, Option, Spinner } from '@fluentui/react-components'
import CustomTable from './CustomTable'
export default function ServerIpsView() { export default function ServerIpsView() {
const [selectedOption, setSelectedOption] = useState<number | null>(null) const [selectedOption, setSelectedOption] = useState<number | null>(null)
@ -9,68 +10,44 @@ export default function ServerIpsView() {
const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption, 0, 10) const { serverIps, isLoading: serversLoading } = useServerIps(selectedOption, 0, 10)
const serverIpsColumns = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'server_id', headerName: 'Server ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'is_actual', headerName: 'Действителен', type: 'boolean' },
{ field: 'ip', headerName: 'IP', type: 'string' },
{ field: 'servername', headerName: 'Название сервера', type: 'string' },
]
useEffect(() => { useEffect(() => {
console.log(serverIps) console.log(serverIps)
}, [serverIps]) }, [serverIps])
return ( return (
<> <>
<form> <Combobox
<Autocomplete clearable
placeholder="Сервер" placeholder="Сервер"
flex={'1'} style={{ flex: 1 }}
data={servers ? servers.map((item: IServer) => ({ label: item.name, value: item.id.toString() })) : []} //onChange={(e) => setSearch(e.currentTarget.value)}
onSelect={(e) => console.log(e.currentTarget.value)} selectedOptions={selectedOption ? [selectedOption.toString()] : []}
//onChange={(value) => setSearch(value)} onOptionSelect={(_, data) => {
onOptionSubmit={(value) => setSelectedOption(Number(value))} if (data.optionValue) {
rightSection={ setSelectedOption(Number(data.optionValue));
//search !== '' && } else {
(
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
//setSearch('')
setSelectedOption(null) setSelectedOption(null)
}}
aria-label="Clear value"
/>
)
} }
//value={search} }}
/> >
</form> {servers?.map((item: IServer) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
</Option>
))}
</Combobox>
{serversLoading ? {serversLoading ?
<Loader /> <Spinner />
: :
<Table highlightOnHover> <CustomTable data={serverIps} columns={[
<Table.Thead> { name: 'id', header: 'ID', type: 'number' },
<Table.Tr> { name: 'server_id', header: 'Server ID', type: 'number' },
{serverIpsColumns.map(column => ( { name: 'name', header: 'Название', type: 'string' },
<Table.Th key={column.field}>{column.headerName}</Table.Th> { name: 'is_actual', header: 'Действителен', type: 'boolean' },
))} { name: 'ip', header: 'IP', type: 'string' },
</Table.Tr> { name: 'servername', header: 'Название сервера', type: 'string' },
</Table.Thead> ]} />
<Table.Tbody>
<Table.Tr
//bg={selectedRows.includes(element.position) ? 'var(--mantine-color-blue-light)' : undefined}
>
{serverIpsColumns.map(column => (
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
} }
</> </>
) )

View File

@ -1,42 +1,26 @@
import { useState } from 'react' import { useState } from 'react'
import { IRegion } from '../interfaces/fuel' import { IRegion } from '../interfaces/fuel'
import { useStorages } from '../hooks/swrHooks' import { useStorages } from '../hooks/swrHooks'
import { Loader, Table } from '@mantine/core' import { Spinner } from '@fluentui/react-components'
import CustomTable from './CustomTable'
export default function ServerStorage() { export default function ServerStorage() {
const [selectedOption] = useState<IRegion | null>(null) const [selectedOption] = useState<IRegion | null>(null)
const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10) const { storages, isLoading: serversLoading } = useStorages(selectedOption?.id, 0, 10)
const storageColumns = [
{ field: 'id', headerName: 'ID', type: 'number' },
{ field: 'hardware_id', headerName: 'Hardware ID', type: 'number' },
{ field: 'name', headerName: 'Название', type: 'string' },
{ field: 'size', headerName: 'Размер', type: 'string' },
{ field: 'storage_type', headerName: 'Тип хранилища', type: 'string' },
]
return ( return (
<> <>
{serversLoading ? {serversLoading ?
<Loader /> <Spinner />
: :
<Table highlightOnHover> <CustomTable data={storages} columns={[
<Table.Thead> { name: 'id', header: 'ID', type: 'number' },
<Table.Tr> { name: 'hardware_id', header: 'Hardware ID', type: 'number' },
{storageColumns.map(column => ( { name: 'name', header: 'Название', type: 'string' },
<Table.Th key={column.field}>{column.headerName}</Table.Th> { name: 'size', header: 'Размер', type: 'string' },
))} { name: 'storage_type', header: 'Тип хранилища', type: 'string' }
</Table.Tr> ]} />
</Table.Thead>
<Table.Tbody>
<Table.Tr>
{storageColumns.map(column => (
<Table.Td key={column.field}>{storages ? storages[column.field] : ''}</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
} }
</> </>
) )

View File

@ -2,7 +2,8 @@ import { useState } from 'react'
import { IRegion } from '../interfaces/fuel' import { IRegion } from '../interfaces/fuel'
import { useRegions, useServers } from '../hooks/swrHooks' import { useRegions, useServers } from '../hooks/swrHooks'
import { useDebounce } from '@uidotdev/usehooks' import { useDebounce } from '@uidotdev/usehooks'
import { Autocomplete, CloseButton, Table } from '@mantine/core' import CustomTable from './CustomTable'
import { Combobox, Option } from '@fluentui/react-components'
export default function ServersView() { export default function ServersView() {
const [search, setSearch] = useState<string | undefined>("") const [search, setSearch] = useState<string | undefined>("")
@ -15,63 +16,46 @@ export default function ServersView() {
const { servers } = useServers(selectedOption, 0, 10) const { servers } = useServers(selectedOption, 0, 10)
const serversColumns = [
//{ field: 'id', headerName: 'ID', type: "number" },
{
field: 'name', headerName: 'Название', type: "string", editable: true,
},
{
field: 'region_id',
editable: true,
headerName: 'region_id',
flex: 1
}
]
return ( return (
<> <>
<form> <Combobox
<Autocomplete clearable
placeholder="Район" placeholder="Район"
flex={'1'} style={{ flex: 1 }}
data={regions ? regions.map((item: IRegion) => ({ label: item.name, value: item.id.toString() })) : []} onChange={(e) => setSearch(e.currentTarget.value)}
onSelect={(e) => console.log(e.currentTarget.value)} selectedOptions={selectedOption ? [selectedOption.toString()] : []}
onChange={(value) => setSearch(value)} onOptionSelect={(_, data) => {
onOptionSubmit={(value) => setSelectedOption(Number(value))} if (data.optionValue) {
rightSection={ setSelectedOption(Number(data.optionValue));
search !== '' && ( } else {
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSearch('')
setSelectedOption(null) setSelectedOption(null)
}}
aria-label="Clear value"
/>
)
} }
value={search} }}
/> >
</form> {regions?.map((item: IRegion) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
<Table highlightOnHover> </Option>
<Table.Thead>
<Table.Tr>
{serversColumns.map(column => (
<Table.Th key={column.field}>{column.headerName}</Table.Th>
))} ))}
</Table.Tr> </Combobox>
</Table.Thead>
<Table.Tbody> {servers &&
<Table.Tr> <CustomTable
{serversColumns.map(column => ( data={servers}
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td> columns={[
))} {
</Table.Tr> name: 'name',
</Table.Tbody> header: 'Название',
</Table> type: "string",
//editable: true,
},
{
name: 'region_id',
//editable: true,
header: 'region_id',
type: 'dictionary'
}
]} />}
</> </>
) )
} }

View File

@ -2,10 +2,9 @@ import { useMemo, useState } from 'react'
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants' import { BASE_URL } from '../../constants'
import { NavLink, Stack, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { setSelectedObjectType } from '../../store/map'; import { setSelectedObjectType } from '../../store/map';
import { setCurrentObjectId, useObjectsStore } from '../../store/objects'; import { setCurrentObjectId, useObjectsStore } from '../../store/objects';
import { Text, Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';
const ObjectTree = ({ const ObjectTree = ({
map_id map_id
@ -52,14 +51,14 @@ const ObjectTree = ({
if (selectedDistrict) { if (selectedDistrict) {
return ( return (
<Stack gap={0}> <div>
<TypeTree map_id={map_id} label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} /> <TypeTree map_id={map_id} label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} />
<TypeTree map_id={map_id} label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} /> <TypeTree map_id={map_id} label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} />
</Stack> </div>
) )
} else { } else {
return ( return (
<Text size='xs'>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text> <Text size={500}>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
) )
} }
@ -84,11 +83,17 @@ const TypeTree = ({
}: TypeTreeProps) => { }: TypeTreeProps) => {
return ( return (
<NavLink px='xs' py={0} label={`${label} ${count ? `(${count})` : ''}`}> <Tree size="small" aria-label="Small Size Tree">
<TreeItem itemType="branch">
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
<Tree>
{Array.isArray(objectList) && objectList.map(list => ( {Array.isArray(objectList) && objectList.map(list => (
<ObjectList map_id={map_id} key={list.id} label={list.name} id={list.id} planning={planning} count={list.count} /> <ObjectList map_id={map_id} key={list.id} label={list.name} id={list.id} planning={planning} count={list.count} />
))} ))}
</NavLink> </Tree>
</TreeItem>
</Tree>
) )
} }
@ -120,15 +125,21 @@ const ObjectList = ({
const navLinks = useMemo(() => ( const navLinks = useMemo(() => (
Array.isArray(data) ? data.map((type) => ( Array.isArray(data) ? data.map((type) => (
<NavLink key={type.object_id} label={type.caption ? type.caption : 'Без названия'} px='xs' py={0} onClick={() => setCurrentObjectId(map_id, type.object_id)} /> <TreeItem itemType='leaf' onClick={() => setCurrentObjectId(map_id, type.object_id)}>
<TreeItemLayout>{type.caption ? type.caption : 'Без названия'}</TreeItemLayout>
</TreeItem>
)) : null )) : null
), [data, map_id]); ), [data, map_id]);
return ( return (
<NavLink onClick={() => setSelectedObjectType(map_id, id)} rightSection={<IconChevronDown size={14} />} px='xs' py={0} label={`${label} ${count ? `(${count})` : ''}`}> <TreeItem itemType='branch' onClick={() => setSelectedObjectType(map_id, id)}>
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
<Tree>
{navLinks} {navLinks}
</NavLink> </Tree>
); </TreeItem>
)
// return ( // return (
// <NavLink onClick={() => { setSelectedObjectType(map_id, id) }} rightSection={<IconChevronDown size={14} />} p={0} label={`${label} ${count ? `(${count})` : ''}`}> // <NavLink onClick={() => { setSelectedObjectType(map_id, id) }} rightSection={<IconChevronDown size={14} />} p={0} label={`${label} ${count ? `(${count})` : ''}`}>

View File

@ -1,4 +1,4 @@
import { useEffect, useRef, useState } from 'react' import { CSSProperties, useEffect, useRef, useState } from 'react'
import 'ol/ol.css' import 'ol/ol.css'
import { Modify } from 'ol/interaction' import { Modify } from 'ol/interaction'
import { ImageStatic, Vector as VectorSource } from 'ol/source' import { ImageStatic, Vector as VectorSource } from 'ol/source'
@ -14,8 +14,8 @@ import { addInteractions, handleImageDrop, loadFeatures, processFigure, processL
import useSWR, { SWRConfiguration } from 'swr' import useSWR, { SWRConfiguration } from 'swr'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants' import { BASE_URL } from '../../constants'
import { ActionIcon, Autocomplete, CloseButton, Flex, Select as MantineSelect, MantineStyleProp, rem, useMantineColorScheme, Portal, Menu, Button, Group, Divider, LoadingOverlay, Stack, Container, Transition, } from '@mantine/core' import { useMantineColorScheme } from '@mantine/core'
import { IconBoxMultiple, IconBoxPadding, IconChevronDown, IconChevronLeft, IconPlus, IconSearch, IconUpload, } from '@tabler/icons-react' import { IconBoxMultiple, IconBoxPadding, IconChevronLeft, IconPlus, IconUpload, } from '@tabler/icons-react'
import { ICitySettings, IFigure, ILine } from '../../interfaces/gis' import { ICitySettings, IFigure, ILine } from '../../interfaces/gis'
import axios from 'axios' import axios from 'axios'
import MapToolbar from './MapToolbar/MapToolbar' import MapToolbar from './MapToolbar/MapToolbar'
@ -34,6 +34,8 @@ import GisService from '../../services/GisService'
import MapMode from './MapMode' import MapMode from './MapMode'
import { satMapsProviders, schemas } from '../../constants/map' import { satMapsProviders, schemas } from '../../constants/map'
import MapPrint from './MapPrint/MapPrint' import MapPrint from './MapPrint/MapPrint'
import { Field, Menu, MenuButton, MenuList, MenuPopover, MenuTrigger, Combobox, Option, Button, Divider, Spinner, Portal } from '@fluentui/react-components'
import { IRegion } from '../../interfaces/fuel'
const swrOptions: SWRConfiguration = { const swrOptions: SWRConfiguration = {
revalidateOnFocus: false revalidateOnFocus: false
@ -177,7 +179,7 @@ const MapComponent = ({
}) })
} }
const mapControlsStyle: MantineStyleProp = { const mapControlsStyle: CSSProperties = {
borderRadius: '4px', borderRadius: '4px',
zIndex: '1', zIndex: '1',
backgroundColor: colorScheme === 'light' ? '#F0F0F0CC' : '#000000CC', backgroundColor: colorScheme === 'light' ? '#F0F0F0CC' : '#000000CC',
@ -381,7 +383,7 @@ const MapComponent = ({
useEffect(() => { useEffect(() => {
if (!selectedRegion) { if (!selectedRegion) {
setSelectedRegion(id, null) setSelectedRegion(id, undefined)
setSelectedYear(id, null) setSelectedYear(id, null)
} }
}, [selectedRegion, selectedDistrict, id]) }, [selectedRegion, selectedDistrict, id])
@ -468,73 +470,154 @@ const MapComponent = ({
<MapPrint id={id} mapElement={mapElement} /> <MapPrint id={id} mapElement={mapElement} />
{active && {active &&
<Portal target='#header-portal'> <Portal mountNode={document.querySelector('#header-portal')}>
<Flex gap={'sm'} direction={'row'}> <div style={{ display: 'flex', gap: '1rem' }}>
<Autocomplete <Combobox
form='search_object'
placeholder="Поиск" placeholder="Поиск"
flex={'1'}
data={searchData ? searchData.map((item: { value: string, id_object: string }) => ({ label: item.value, value: item.id_object.toString() })) : []}
//onSelect={(e) => console.log(e.currentTarget.value)}
onChange={(value) => setSearchObject(value)}
onOptionSubmit={(value) => setCurrentObjectId(id, value)}
rightSection={
searchObject !== '' && (
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSearchObject('')
}}
aria-label="Clear value"
/>
)
}
leftSection={<IconSearch size={16} />}
value={searchObject} value={searchObject}
/> onOptionSelect={(_ev, data) => {
if (data.optionValue) {
<MantineSelect setCurrentObjectId(id, data.optionValue);
placeholder="Регион" setSearchObject(
flex={'1'} searchData?.find((item: any) => item.id_object.toString() === data.optionValue)?.value ?? ""
data={regionsData ? regionsData.map((item: { name: string, id: number }) => ({ label: item.name, value: item.id.toString() })) : []} );
onChange={(value) => setSelectedRegion(id, Number(value))}
clearable
onClear={() => setSelectedRegion(id, null)}
searchable
value={selectedRegion ? selectedRegion.toString() : null}
/>
<MantineSelect
placeholder="Населённый пункт"
flex={'1'}
data={districtsData ? districtsData.map((item: { name: string, id: number, district_name: string }) => ({ label: [item.name, item.district_name].join(' - '), value: item.id.toString() })) : []}
onChange={(value) => setSelectedDistrict(id, Number(value))}
clearable
onClear={() => { setSelectedDistrict(id, null) }}
searchable
value={selectedDistrict ? selectedDistrict.toString() : null}
/>
<MantineSelect placeholder='Схема' w='92px'
data={schemas.map(el => ({ label: el, value: el }))}
onChange={(e) => {
if (e) {
setSelectedYear(id, Number(e))
} else {
setSelectedYear(id, null)
} }
}} }}
onClear={() => setSelectedYear(id, null)} onChange={(e) => {
value={selectedYear ? selectedYear?.toString() : null} setSearchObject(e.currentTarget.value); // free typing like Mantine's onChange
}}
clearable clearable
/> style={{ minWidth: 'auto' }}
>
{searchData
? searchData.map((item: { value: string; id_object: string }) => (
<Option key={item.id_object} value={item.id_object.toString()}>
{item.value}
</Option>
))
: null}
</Combobox>
<Button variant={alignMode ? 'filled' : 'transparent'} onClick={() => setAlignMode(id, !alignMode)}> <Combobox
<IconBoxPadding style={{ width: rem(20), height: rem(20) }} /> placeholder="Регион"
</Button> clearable
// 👇 show label instead of id
value={
selectedRegion
? regionsData?.find((item: IRegion) => item.id === selectedRegion)?.name ?? ""
: ""
}
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setSelectedRegion(id, Number(data.optionValue));
} else {
setSelectedRegion(id, undefined);
}
}}
style={{ minWidth: 'auto' }}
>
{regionsData
? regionsData.map((item: { name: string; id: number }) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
</Option>
))
: null}
</Combobox>
<Menu position="bottom-end" transitionProps={{ transition: 'pop-top-right' }}> <Combobox
placeholder="Населённый пункт"
clearable
value={
selectedDistrict
? districtsData?.find((item: { id: number }) => item.id === selectedDistrict)?.name +
" - " +
districtsData?.find((item: { id: number }) => item.id === selectedDistrict)?.district_name
: ""
}
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setSelectedDistrict(id, Number(data.optionValue));
} else {
setSelectedDistrict(id, null);
}
}}
style={{ minWidth: 'auto' }}
>
{districtsData
? districtsData.map(
(item: { name: string; id: number; district_name: string }) => (
<Option text={`${item.name} - ${item.district_name}`} key={item.id} value={item.id.toString()}>
{item.name} - {item.district_name}
</Option>
)
)
: null}
</Combobox>
<Combobox
placeholder="Схема"
clearable
style={{ width: "92px", minWidth: 'auto' }}
value={selectedYear ? selectedYear.toString() : ""}
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setSelectedYear(id, Number(data.optionValue));
} else {
setSelectedYear(id, null);
}
}}
>
{schemas.map((el) => (
<Option key={el} value={el}>
{el}
</Option>
))}
</Combobox>
<Button icon={<IconBoxPadding />} appearance={alignMode ? 'primary' : 'transparent'} onClick={() => setAlignMode(id, !alignMode)} />
<Menu persistOnItemClick positioning={{ autoSize: true }}>
<MenuTrigger disableButtonEnhancement>
<MenuButton appearance='subtle' icon={<IconBoxMultiple />}>Слои</MenuButton>
</MenuTrigger>
<MenuPopover>
<MenuList>
<Field>Настройка видимости слоёв</Field>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Combobox
defaultValue={satMapsProviders.find(provider => provider.value === satMapsProvider)?.label ?? ""}
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setSatMapsProvider(id, data.optionValue as SatelliteMapsProvider);
}
}}
>
{satMapsProviders.map((provider) => (
<Option text={provider.label} key={provider.value} value={provider.value}>
{provider.label}
</Option>
))}
</Combobox>
</div>
<div style={{
display: 'flex',
}}>
<Button icon={<IconUpload />} appearance='transparent' onClick={() => submitOverlay(file, polygonExtent, rectCoords)} />
<Button icon={<IconPlus />} appearance='transparent' title='Добавить подложку' />
</div>
<MapLayers map={map} />
</MenuList>
</MenuPopover>
</Menu>
{/* <Menu position="bottom-end" transitionProps={{ transition: 'pop-top-right' }}>
<Menu.Target> <Menu.Target>
<Button variant='transparent'> <Button variant='transparent'>
<Group gap={7} wrap='nowrap' style={{ flexShrink: 0 }} title='Слои'> <Group gap={7} wrap='nowrap' style={{ flexShrink: 0 }} title='Слои'>
@ -562,68 +645,99 @@ const MapComponent = ({
<MapLayers map={map} /> <MapLayers map={map} />
</Flex> </Flex>
</Menu.Dropdown> </Menu.Dropdown>
</Menu> </Menu> */}
</Flex> </div>
</Portal > </Portal >
} }
<Container pos='absolute' w='100%' h='100%' p='0' fluid> <div style={{ position: 'absolute', width: '100%', height: '100%' }}>
<Flex direction='column' w='100%' h='100%'> <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }}>
<Flex w='100%' h='94%' p='xs' style={{ flexGrow: 1 }}> <div style={{ display: 'flex', width: '100%', height: '94%', padding: '0.5rem', flexGrow: 1 }}>
<Stack w='100%' maw='380px'> <div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '380px' }}>
<Flex w='100%' h='100%' gap='xs'> <div style={{ display: 'flex', width: '100%', height: '100%', gap: '0.5rem' }}>
{selectedRegion && selectedDistrict && selectedYear && {selectedRegion && selectedDistrict && selectedYear &&
<Flex direction='column' h={'100%'} w={leftPaneHidden ? '0px' : '100%'} style={{ ...mapControlsStyle, transition: 'width .3s ease' }}> <div
style={{
...mapControlsStyle,
transition: 'width .3s ease',
display: 'flex',
flexDirection: 'column',
height: '100%',
width: leftPaneHidden ? '0px' : '100%',
overflow: 'hidden'
}}
>
<TabsPane defaultTab='objects' tabs={objectsPane} /> <TabsPane defaultTab='objects' tabs={objectsPane} />
<Divider /> <Divider />
<TabsPane defaultTab='parameters' tabs={paramsPane} /> <TabsPane defaultTab='parameters' tabs={paramsPane} />
</Flex> </div>
} }
{!!selectedRegion && !!selectedDistrict && !!selectedYear && {!!selectedRegion && !!selectedDistrict && !!selectedYear &&
<Button p='0' variant='subtle' w='32' style={{ zIndex: '1' }} onClick={() => setLeftPaneHidden(!leftPaneHidden)}> <Button
<IconChevronLeft size={16} style={{ transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}` }} /> icon={<IconChevronLeft size={16}
</Button> style={{
transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}`,
}} />}
style={{
zIndex: '1',
display: 'flex',
height: 'min-content'
}}
appearance='subtle'
onClick={() => setLeftPaneHidden(!leftPaneHidden)}
/>
} }
</Flex> </div>
</Stack> </div>
<Stack w='100%' align='center'> <div style={{ display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center' }} >
<Stack style={mapControlsStyle} w='fit-content'> <div style={{ ...mapControlsStyle, display: 'flex', flexDirection: 'column', width: 'fit-content' }}>
<MapMode map_id={id} /> <MapMode map_id={id} />
</Stack> </div>
</Stack> </div>
<Stack w='100%' maw='340px' align='flex-end' justify='space-between'> <div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '340px', alignItems: 'flex-end', justifyContent: 'space-between' }}>
{selectedRegion && selectedDistrict && selectedYear && mode === 'edit' && {selectedRegion && selectedDistrict && selectedYear && mode === 'edit' &&
<MapToolbar map_id={id} /> <MapToolbar map_id={id} />
} }
<Transition {!!selectedRegion && !!selectedDistrict && !!selectedYear &&
mounted={!!selectedRegion && !!selectedDistrict && !!selectedYear} <MapLegend selectedDistrict={selectedDistrict} selectedYear={selectedYear} />
transition="slide-left" }
duration={200} </div>
timingFunction="ease" </div>
>
{(styles) => <MapLegend style={styles} selectedDistrict={selectedDistrict} selectedYear={selectedYear} />}
</Transition>
</Stack>
</Flex>
<Flex w='100%'> <div style={{ display: 'flex', width: '100%' }}>
<MapStatusbar <MapStatusbar
map_id={id} map_id={id}
mapControlsStyle={mapControlsStyle} mapControlsStyle={mapControlsStyle}
/> />
</Flex> </div>
</Flex> </div>
</Container> </div>
<Container pos='absolute' fluid p={0} w='100%' h='100%' mah='100%' ref={mapElement} onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleImageDrop(e, id)}> <div style={{ position: 'absolute', width: '100%', height: '100%', maxHeight: '100%' }} ref={mapElement} onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleImageDrop(e, id)}>
<div ref={tooltipRef}></div> <div ref={tooltipRef}></div>
</Container> </div>
{(linesValidating || figuresValidating) && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "rgba(255, 255, 255, 0.6)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 9999,
}}
>
<Spinner size="large" label="Загрузка..." />
</div>
)}
<LoadingOverlay visible={linesValidating || figuresValidating} />
</> </>
) )
} }

View File

@ -1,4 +1,4 @@
import { Checkbox, Flex, NavLink, Slider, Stack } from '@mantine/core' import { Checkbox, Slider, Text } from '@fluentui/react-components'
import BaseLayer from 'ol/layer/Base' import BaseLayer from 'ol/layer/Base'
import Map from 'ol/Map' import Map from 'ol/Map'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
@ -11,11 +11,11 @@ const MapLayers = ({
map map
}: MapLayersProps) => { }: MapLayersProps) => {
return ( return (
<Stack gap='0'> <div style={{ display: 'flex', flexDirection: 'column' }}>
{map?.getLayers().getArray() && map?.getLayers().getArray().map((layer, index) => ( {map?.getLayers().getArray() && map?.getLayers().getArray().map((layer, index) => (
<LayerSetting key={index} index={index} layer={layer} /> <LayerSetting key={index} index={index} layer={layer} />
))} ))}
</Stack> </div>
) )
} }
@ -40,21 +40,23 @@ const LayerSetting = ({
}, [opacity, layer]) }, [opacity, layer])
return ( return (
<Flex key={`layer-${index}`} gap='xs' align='center'> <div style={{ display: 'flex', alignItems: 'center' }} key={`layer-${index}`}>
<Checkbox <Checkbox
checked={visible} checked={visible}
onChange={(e) => setVisible(e.currentTarget.checked)} onChange={(e) => setVisible(e.currentTarget.checked)}
/> />
<Slider <Slider
w='100%' width='100%'
min={0} min={0}
max={1} max={1}
step={0.001} step={0.001}
value={opacity} value={opacity}
onChange={(value) => setOpacity(value)} onChange={(_, data) => setOpacity(data.value)}
/> />
<NavLink p={0} label={layer.get('name')} onClick={() => { console.log(layer.getLayerState()) }} /> <Text truncate size={300} onClick={() => { console.log(layer.getLayerState()) }}>
</Flex> {layer.get('name')}
</Text>
</div>
) )
} }

View File

@ -1,18 +1,15 @@
import { Accordion, ActionIcon, Collapse, ColorSwatch, Flex, MantineStyleProp, ScrollAreaAutosize, Stack, Text, useMantineColorScheme } from '@mantine/core' import { useMantineColorScheme } from '@mantine/core'
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher } from '../../../http/axiosInstance' import { fetcher } from '../../../http/axiosInstance'
import { BASE_URL } from '../../../constants' import { BASE_URL } from '../../../constants'
import { useDisclosure } from '@mantine/hooks' import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, ColorSwatch, Text } from '@fluentui/react-components'
import { IconChevronDown } from '@tabler/icons-react'
const MapLegend = ({ const MapLegend = ({
selectedDistrict, selectedDistrict,
selectedYear, selectedYear,
style
}: { }: {
selectedDistrict: number | null, selectedDistrict: number | null,
selectedYear: number | null, selectedYear: number | null,
style: MantineStyleProp
}) => { }) => {
const { colorScheme } = useMantineColorScheme() const { colorScheme } = useMantineColorScheme()
@ -32,40 +29,42 @@ const MapLegend = ({
} }
) )
const [opened, { toggle }] = useDisclosure(false)
return ( return (
<ScrollAreaAutosize maw='300px' w='100%' fz='xs' mt='auto' style={{ ...style, zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}> <div
<Stack gap='sm' p='sm'> style={{ overflow: 'auto', maxWidth: '300px', width: '100%', marginTop: 'auto', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}
<Flex align='center'> >
<Text fz='xs'> <Accordion collapsible>
<AccordionItem value='existing'>
<AccordionHeader>
Легенда Легенда
</Text> </AccordionHeader>
<ActionIcon ml='auto' variant='subtle' onClick={toggle} > <AccordionPanel>
<IconChevronDown style={{ transform: opened ? 'rotate(0deg)' : 'rotate(180deg)' }} /> <Accordion multiple collapsible>
</ActionIcon> <AccordionItem value='existing'>
</Flex> <AccordionHeader>
Существующие
</AccordionHeader>
<Collapse in={opened}> <AccordionPanel>
<Accordion defaultValue={['existing', 'planning']} multiple>
<Accordion.Item value='existing' key='existing'>
<Accordion.Control>Существующие</Accordion.Control>
<Accordion.Panel>
{existingObjectsList && <LegendGroup objectsList={existingObjectsList} border='solid' />} {existingObjectsList && <LegendGroup objectsList={existingObjectsList} border='solid' />}
</Accordion.Panel> </AccordionPanel>
</Accordion.Item> </AccordionItem>
<Accordion.Item value='planning' key='planning'> <AccordionItem value='planning'>
<Accordion.Control>Планируемые</Accordion.Control> <AccordionHeader>
<Accordion.Panel> Планируемые
</AccordionHeader>
<AccordionPanel>
{planningObjectsList && <LegendGroup objectsList={planningObjectsList} border='dotted' />} {planningObjectsList && <LegendGroup objectsList={planningObjectsList} border='dotted' />}
</Accordion.Panel> </AccordionPanel>
</Accordion.Item> </AccordionItem>
</Accordion> </Accordion>
</Collapse> </AccordionPanel>
</Stack> </AccordionItem>
</ScrollAreaAutosize> </Accordion>
</div>
) )
} }
@ -89,15 +88,15 @@ const LegendGroup = ({
} }
return ( return (
<Stack gap={4}> <div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{objectsList.map(object => ( {objectsList.map(object => (
<Flex gap='xs' align='center' key={object.id}> <div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }} key={object.id}>
<ColorSwatch style={{ border: borderStyle() }} radius={0} size={16} color={`rgb(${object.r},${object.g},${object.b})`} /> <ColorSwatch size='extra-small' style={{ border: borderStyle() }} color={`rgb(${object.r},${object.g},${object.b})`} value={`rgb(${object.r},${object.g},${object.b})`} />
- -
<Text fz='xs'>{object.name}</Text> <Text size={200}>{object.name}</Text>
</Flex> </div>
))} ))}
</Stack> </div>
) )
} }

View File

@ -1,6 +1,5 @@
import { useEffect, useRef } from 'react' import { useEffect, useRef, useState } from 'react'
import 'ol/ol.css' import 'ol/ol.css'
import { Container, Stack, Tabs } from '@mantine/core'
import OlMap from 'ol/Map' import OlMap from 'ol/Map'
import { v4 as uuidv4 } from 'uuid' import { v4 as uuidv4 } from 'uuid'
import TileLayer from 'ol/layer/Tile' import TileLayer from 'ol/layer/Tile'
@ -12,6 +11,7 @@ import VectorSource from 'ol/source/Vector'
import Feature from 'ol/Feature' import Feature from 'ol/Feature'
import { LineString } from 'ol/geom' import { LineString } from 'ol/geom'
import { Stroke, Style, Text } from 'ol/style' import { Stroke, Style, Text } from 'ol/style'
import { Tab, TabList } from '@fluentui/react-components'
const center = [14443331.466543002, 8878970.176309839] const center = [14443331.466543002, 8878970.176309839]
@ -106,19 +106,21 @@ const MapLineTest = () => {
} }
}, []) }, [])
const [selectedTab, setSelectedTab] = useState<string | unknown>('map')
return ( return (
<Container fluid w='100%' pos='relative' p={0}> <div style={{ position: 'relative', width: '100%' }}>
<Tabs h='100%' variant='default' value={'map'} keepMounted={true}>
<Stack gap={0} h='100%'> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Tabs.List> <TabList selectedValue='map' onTabSelect={(_, data) => setSelectedTab(data.value)}>
<Tabs.Tab value={'map'}>Map</Tabs.Tab> <Tab value='map'>
</Tabs.List> Map
<Tabs.Panel value={'map'} h='100%' pos='relative'> </Tab>
<Container pos='absolute' fluid p={0} w='100%' h='100%' ref={mapElement}></Container> </TabList>
</Tabs.Panel> {selectedTab === 'map' && <div style={{ width: '100%', height: '100%' }} ref={mapElement}></div>}
</Stack>
</Tabs> </div>
</Container> </div>
) )
} }

View File

@ -1,106 +1,88 @@
import { Button, Flex, FloatingIndicator, Popover, SegmentedControl } from '@mantine/core'
import { Mode, setMode, useMapStore } from '../../store/map' import { Mode, setMode, useMapStore } from '../../store/map'
import { IconChevronDown, IconCropLandscape, IconCropPortrait, IconEdit, IconEye, IconPrinter } from '@tabler/icons-react' import { IconCropLandscape, IconCropPortrait, IconEdit, IconEye, IconPrinter } from '@tabler/icons-react'
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { PrintOrientation, setPrintOrientation, usePrintStore } from '../../store/print' import { PrintOrientation, setPrintOrientation, usePrintStore } from '../../store/print'
import { Button, Menu, MenuItemRadio, MenuList, MenuPopover, MenuProps, MenuTrigger, SplitButton } from '@fluentui/react-components'
const MapMode = ({ const MapMode = ({
map_id map_id
}: { map_id: string }) => { }: { map_id: string }) => {
const [rootRef, setRootRef] = useState<HTMLDivElement | null>(null);
const [controlsRefs, setControlsRefs] = useState<Record<string, HTMLButtonElement | null>>({});
const { mode } = useMapStore().id[map_id] const { mode } = useMapStore().id[map_id]
const setControlRef = (item: Mode) => (node: HTMLButtonElement) => {
controlsRefs[item] = node;
setControlsRefs(controlsRefs);
}
const { printOrientation } = usePrintStore() const { printOrientation } = usePrintStore()
useEffect(() => { const [checkedValues, setCheckedValues] = useState<Record<string, string[]>>({ orientation: [printOrientation] })
const onChange: MenuProps["onCheckedValueChange"] = (
_,
{ name, checkedItems }
) => {
setCheckedValues((s) => ({ ...s, [name]: checkedItems }))
setPrintOrientation(checkedItems[0] as PrintOrientation)
setMode(map_id, 'print' as Mode)
}
useEffect(() => {
if (printOrientation) {
setCheckedValues((s) => ({ ...s, ['orientation']: [printOrientation] }))
}
}, [printOrientation]) }, [printOrientation])
return ( return (
<Flex ref={setRootRef} p={4} gap={4}> <div style={{
display: 'flex',
gap: '0.25rem',
padding: '0.25rem'
}}>
<Button <Button
variant={mode === 'view' ? 'filled' : 'subtle'} appearance={mode === 'view' ? 'primary' : 'subtle'}
key={'view'} key={'view'}
ref={setControlRef('view' as Mode)}
onClick={() => { onClick={() => {
setMode(map_id, 'view' as Mode) setMode(map_id, 'view' as Mode)
}} }}
leftSection={<IconEye size={16} />} icon={<IconEye size={16} />}
mod={{ active: mode === 'view' as Mode }} //mod={{ active: mode === 'view' as Mode }}
> >
Просмотр Просмотр
</Button> </Button>
<Button <Button
variant={mode === 'edit' ? 'filled' : 'subtle'} appearance={mode === 'edit' ? 'primary' : 'subtle'}
key={'edit'} key={'edit'}
ref={setControlRef('edit' as Mode)}
onClick={() => { onClick={() => {
setMode(map_id, 'edit' as Mode) setMode(map_id, 'edit' as Mode)
}} }}
leftSection={<IconEdit size={16} />} icon={<IconEdit size={16} />}
mod={{ active: mode === 'edit' as Mode }} //mod={{ active: mode === 'edit' as Mode }}
> >
Редактирование Редактирование
</Button> </Button>
<Popover width='auto' position='bottom-end' > <Menu checkedValues={checkedValues} onCheckedValueChange={onChange}>
<Popover.Target> <MenuTrigger>
<Button.Group> <SplitButton
<Button appearance={mode === 'print' ? 'primary' : 'subtle'}
variant={mode === 'print' ? 'filled' : 'subtle'} primaryActionButton={{
key={'print'} onClick: () => setMode(map_id, 'print' as Mode)
ref={setControlRef('print' as Mode)}
onClick={(e) => {
e.stopPropagation()
setMode(map_id, 'print' as Mode)
}} }}
leftSection={<IconPrinter size={16} />} icon={<IconPrinter size={16} />}
mod={{ active: mode === 'print' as Mode }}
> >
Печать Печать
</Button> </SplitButton>
<Button variant={mode === 'print' ? 'filled' : 'subtle'} w='auto' p={8} title='Ориентация'> </MenuTrigger>
<IconChevronDown size={16} />
</Button>
</Button.Group>
</Popover.Target>
<Popover.Dropdown p={0} style={{ display: 'flex' }}> <MenuPopover>
<SegmentedControl <MenuList>
color='blue' <MenuItemRadio name='orientation' value='horizontal' icon={<IconCropLandscape style={{ display: 'block' }} />}>
value={printOrientation} Горизонтальная
onChange={(value) => { </MenuItemRadio>
setPrintOrientation(value as PrintOrientation) <MenuItemRadio name='orientation' value='vertical' icon={<IconCropPortrait style={{ display: 'block' }} />}>
setMode(map_id, 'print' as Mode) Вертикальная
}} </MenuItemRadio>
data={[ </MenuList>
{ </MenuPopover>
value: 'horizontal', </Menu>
label: ( </div >
<IconCropLandscape title='Горизонтальная' style={{ display: 'block' }} size={20} />
),
},
{
value: 'vertical',
label: (
<IconCropPortrait title='Вертикальная' style={{ display: 'block' }} size={20} />
),
},
]}
/>
</Popover.Dropdown>
</Popover>
<FloatingIndicator target={controlsRefs[mode]} parent={rootRef} />
</Flex >
) )
} }

View File

@ -1,5 +1,4 @@
import { ActionIcon, Button, Checkbox, Flex, Modal, Radio, ScrollAreaAutosize, Select, Stack, Text } from '@mantine/core' import { IconHelp, IconWindowMaximize, IconWindowMinimize, IconX } from '@tabler/icons-react'
import { IconHelp, IconWindowMaximize, IconWindowMinimize } from '@tabler/icons-react'
import React, { useEffect, useRef, useState } from 'react' import React, { useEffect, useRef, useState } from 'react'
import { clearPrintArea, PrintScale, setPreviousView, setPrintScale, setPrintScaleLine, useMapStore } from '../../../store/map' import { clearPrintArea, PrintScale, setPreviousView, setPrintScale, setPrintScaleLine, useMapStore } from '../../../store/map'
import { PrintFormat, PrintOrientation, printResolutions, setPrintOrientation, setPrintResolution, usePrintStore } from '../../../store/print' import { PrintFormat, PrintOrientation, printResolutions, setPrintOrientation, setPrintResolution, usePrintStore } from '../../../store/print'
@ -9,6 +8,7 @@ import { useObjectsStore } from '../../../store/objects'
import jsPDF from 'jspdf' import jsPDF from 'jspdf'
import { getCenter } from 'ol/extent' import { getCenter } from 'ol/extent'
import ScaleLine from 'ol/control/ScaleLine' import ScaleLine from 'ol/control/ScaleLine'
import { Button, Checkbox, Dropdown, Field, Option, Radio, RadioGroup, Text } from '@fluentui/react-components'
const MapPrint = ({ const MapPrint = ({
id, id,
@ -140,94 +140,128 @@ const MapPrint = ({
} }
}, [printScaleLine, printArea]) }, [printScaleLine, printArea])
return ( const [opened, setOpened] = useState(false)
<Modal.Root
scrollAreaComponent={ScrollAreaAutosize} useEffect(() => {
keepMounted size='auto' if (!!printArea) {
opened={!!printArea} setOpened(true)
onClose={() => { }
}, [printArea])
useEffect(() => {
if (!opened) {
clearPrintArea(id) clearPrintArea(id)
map?.setTarget(mapElement.current as HTMLDivElement) map?.setTarget(mapElement.current as HTMLDivElement)
map?.addInteraction(printAreaDraw) map?.addInteraction(printAreaDraw)
}} fullScreen={fullscreen}> }
<Modal.Overlay /> }, [opened])
<Modal.Content style={{ transition: 'all .3s ease' }}>
<Modal.Header>
<Modal.Title>
Предпросмотр области печати
</Modal.Title>
<Flex ml='auto' gap='md'> return (
<ActionIcon title='Помощь' ml='auto' variant='transparent'> <div
<IconHelp color='gray' /> style={{
</ActionIcon> display: opened ? 'flex' : 'none',
<ActionIcon title={fullscreen ? 'Свернуть' : 'Развернуть'} variant='transparent' onClick={() => setFullscreen(!fullscreen)}> position: fullscreen ? 'fixed' : 'fixed',
{fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />} zIndex: '9999',
</ActionIcon> width: '100%',
<Modal.CloseButton title='Закрыть' /> height: '100%',
</Flex> inset: 0,
</Modal.Header> justifyContent: 'center',
<Modal.Body> alignItems: 'center',
<Stack align='center'> }}
<Text w='100%'>Область печати можно передвигать.</Text> >
<div style={{
display: 'flex',
flexDirection: 'column',
transition: 'all .3s ease',
width: fullscreen ? '100%' : 'auto',
height: fullscreen ? '100%' : 'fit-content',
background: 'var(--colorNeutralBackground1)',
border: '1px solid var(--colorNeutralShadowKey)',
}}>
<div style={{ display: 'flex', padding: '1rem', alignItems: 'center' }}>
<Text>
Предпросмотр области печати
</Text>
<div style={{ display: 'flex', marginLeft: 'auto', gap: '1.5rem' }}>
<Button appearance='subtle' title='Помощь' style={{ marginLeft: 'auto' }} icon={<IconHelp color='gray' />} />
<Button appearance='subtle' title={fullscreen ? 'Свернуть' : 'Развернуть'} style={{ marginLeft: 'auto' }} icon={fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />} onClick={() => setFullscreen(!fullscreen)} />
<Button appearance='subtle' title='Закрыть' icon={<IconX />} onClick={() => setOpened(false)} />
</div>
</div>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', height: 'fit-content', overflow: 'auto' }}>
<Text>Область печати можно передвигать.</Text>
<div id='print-portal' style={{ <div id='print-portal' style={{
width: printOrientation === 'horizontal' ? '594px' : '420px', width: printOrientation === 'horizontal' ? '594px' : '420px',
height: printOrientation === 'horizontal' ? '420px' : '594px' height: printOrientation === 'horizontal' ? '420px' : '594px',
flexShrink: '0'
}}> }}>
</div> </div>
<Flex w='100%' wrap='wrap' gap='lg' justify='space-between'> <div style={{ display: 'flex', width: '100%', flexWrap: 'wrap', gap: '1rem', padding: '1rem', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Radio.Group <Field label={'Ориентация'}>
label='Ориентация' <RadioGroup value={printOrientation} onChange={(_, data) => setPrintOrientation(data.value as PrintOrientation)}>
value={printOrientation}
onChange={(value) => setPrintOrientation(value as PrintOrientation)}
>
<Stack>
<Radio value='horizontal' label='Горизонтальная' /> <Radio value='horizontal' label='Горизонтальная' />
<Radio value='vertical' label='Вертикальная' /> <Radio value='vertical' label='Вертикальная' />
</Stack> </RadioGroup>
</Radio.Group> </Field>
<Select <Field label="Разрешение">
allowDeselect={false} <Dropdown
label="Разрешение" defaultValue={printResolution.toString()}
placeholder="Выберите разрешение"
data={printResolutions}
value={printResolution.toString()} value={printResolution.toString()}
onChange={(value) => setPrintResolution(Number(value))} selectedOptions={[printResolution.toString()]}
/> onOptionSelect={(_, data) => setPrintResolution(Number(data.optionValue))}
>
{printResolutions.map((res) => (
<Option key={res} text={res} value={res}>
{res}
</Option>
))}
</Dropdown>
</Field>
<Select
allowDeselect={false} <Field label="Масштаб">
label="Масштаб" <Dropdown
placeholder="Выберите масштаб" defaultValue={printScale.toString()}
data={scaleOptions} value={printScale.toString()}
value={printScale} defaultSelectedOptions={[printScale]}
onChange={(value) => setPrintScale(id, value as PrintScale)} selectedOptions={[printScale]}
/> onOptionSelect={(_, data) => setPrintScale(id, data.optionValue as PrintScale)}
>
{scaleOptions.map((opt) => (
<Option key={opt.value} text={opt.label} value={opt.value}>
{opt.label}
</Option>
))}
</Dropdown>
</Field>
<Checkbox <Checkbox
checked={printScaleLine} checked={printScaleLine}
label="Масштабная линия" label="Масштабная линия"
onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)} onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)}
/> />
</Flex> </div>
<Flex w='100%' gap='sm' align='center'> <div style={{ display: 'flex', width: '100%', gap: '1rem', padding: '1rem', alignItems: 'center' }}>
<Button ml='auto' onClick={() => { <Button style={{ marginLeft: 'auto' }} onClick={() => {
if (previousView) { if (previousView) {
exportToPDF(printFormat, printResolution, printOrientation) exportToPDF(printFormat, printResolution, printOrientation)
} }
}}> }}>
Печать Печать
</Button> </Button>
</Flex> </div>
</Stack> </div>
</Modal.Body> </div>
</Modal.Content> </div>
</Modal.Root>
) )
} }

View File

@ -1,6 +1,6 @@
import { Divider, Flex, rem, Text } from '@mantine/core'
import { CSSProperties } from 'react' import { CSSProperties } from 'react'
import { useMapStore } from '../../../store/map'; import { useMapStore } from '../../../store/map';
import { Divider, Text } from '@fluentui/react-components';
interface IMapStatusbarProps { interface IMapStatusbarProps {
mapControlsStyle: CSSProperties; mapControlsStyle: CSSProperties;
@ -14,33 +14,33 @@ const MapStatusbar = ({
const { currentCoordinate, currentX, currentY, currentZ, statusText } = useMapStore().id[map_id] const { currentCoordinate, currentX, currentY, currentZ, statusText } = useMapStore().id[map_id]
return ( return (
<Flex gap='sm' p={'4px'} w={'100%'} fz={'xs'} style={{ ...mapControlsStyle, borderRadius: 0 }}> <div style={{ ...mapControlsStyle, display: 'flex', gap: '1rem', padding: '0.25rem', width: '100%', borderRadius: 0 }}>
<Text fz='xs' w={rem(130)}> <Text size={200}>
x: {currentCoordinate?.[0]} x: {currentCoordinate?.[0]}
</Text> </Text>
<Text fz='xs' w={rem(130)}> <Text size={200}>
y: {currentCoordinate?.[1]} y: {currentCoordinate?.[1]}
</Text> </Text>
<Divider orientation='vertical' /> <Divider vertical />
<Text fz='xs'> <Text size={200}>
Z={currentZ} Z={currentZ}
</Text> </Text>
<Text fz='xs'> <Text size={200}>
X={currentX} X={currentX}
</Text> </Text>
<Text fz='xs'> <Text size={200}>
Y={currentY} Y={currentY}
</Text> </Text>
<Text fz='xs' ml='auto'> <Text size={200} style={{marginLeft: 'auto'}}>
{statusText} {statusText}
</Text> </Text>
</Flex> </div>
) )
} }

View File

@ -1,7 +1,8 @@
import { ActionIcon, Flex, useMantineColorScheme } from '@mantine/core' import { useMantineColorScheme } from '@mantine/core'
import { IconArrowBackUp, IconArrowsMove, IconCircle, IconExclamationCircle, IconLine, IconPoint, IconPolygon, IconRuler, IconTransformPoint } from '@tabler/icons-react' import { IconArrowBackUp, IconArrowsMove, IconCircle, IconExclamationCircle, IconLine, IconPoint, IconPolygon, IconRuler, IconTransformPoint } from '@tabler/icons-react'
import { getDraw, setCurrentTool, useMapStore } from '../../../store/map'; import { getDraw, setCurrentTool, useMapStore } from '../../../store/map';
import { saveFeatures } from '../mapUtils'; import { saveFeatures } from '../mapUtils';
import { Button } from '@fluentui/react-components';
const MapToolbar = ({ const MapToolbar = ({
map_id map_id
@ -10,81 +11,41 @@ const MapToolbar = ({
const { colorScheme } = useMantineColorScheme(); const { colorScheme } = useMantineColorScheme();
return ( return (
<Flex> <div style={{ display: 'flex' }}>
<ActionIcon.Group orientation='vertical' style={{ zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}> <div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
<ActionIcon size='lg' variant='transparent' onClick={() => saveFeatures(map_id)}> <Button icon={<IconExclamationCircle />} appearance='transparent' onClick={() => saveFeatures(map_id)} />
<IconExclamationCircle />
</ActionIcon>
<ActionIcon size='lg' variant='transparent' onClick={() => getDraw(map_id)?.removeLastPoint()}> <Button icon={<IconArrowBackUp />} appearance='transparent' onClick={() => getDraw(map_id)?.removeLastPoint()} />
<IconArrowBackUp />
</ActionIcon>
<ActionIcon <Button icon={<IconTransformPoint />} appearance={currentTool === 'Edit' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Edit' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Edit') setCurrentTool(map_id, 'Edit')
}}> }} />
<IconTransformPoint />
</ActionIcon>
<ActionIcon <Button icon={<IconPoint />} appearance={currentTool === 'Point' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Point' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Point') setCurrentTool(map_id, 'Point')
}}> }} />
<IconPoint />
</ActionIcon>
<ActionIcon <Button icon={<IconLine />} appearance={currentTool === 'LineString' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'LineString' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'LineString') setCurrentTool(map_id, 'LineString')
}}> }} />
<IconLine />
</ActionIcon>
<ActionIcon <Button icon={<IconPolygon />} appearance={currentTool === 'Polygon' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Polygon' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Polygon') setCurrentTool(map_id, 'Polygon')
}}> }} />
<IconPolygon />
</ActionIcon>
<ActionIcon <Button icon={<IconCircle />} appearance={currentTool === 'Circle' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Circle' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Circle') setCurrentTool(map_id, 'Circle')
}}> }} />
<IconCircle />
</ActionIcon>
<ActionIcon <Button icon={<IconArrowsMove />} appearance={currentTool === 'Mover' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Mover' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Mover') setCurrentTool(map_id, 'Mover')
}} }} />
>
<IconArrowsMove />
</ActionIcon>
<ActionIcon <Button icon={<IconRuler />} appearance={currentTool === 'Measure' ? 'primary' : 'transparent'} onClick={() => {
size='lg'
variant={currentTool === 'Measure' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Measure') setCurrentTool(map_id, 'Measure')
}}> }} />
<IconRuler /> </div>
</ActionIcon> </div>
</ActionIcon.Group>
</Flex>
) )
} }

View File

@ -1,34 +0,0 @@
import { Checkbox, Group, RenderTreeNodePayload, Text } from "@mantine/core";
import { IconChevronDown } from "@tabler/icons-react";
export const MapTreeCheckbox = ({
node,
expanded,
hasChildren,
elementProps,
tree,
}: RenderTreeNodePayload) => {
const checked = tree.isNodeChecked(node.value);
const indeterminate = tree.isNodeIndeterminate(node.value);
return (
<Group gap="xs" {...elementProps}>
<Checkbox.Indicator
checked={checked}
indeterminate={indeterminate}
onClick={() => (!checked ? tree.checkNode(node.value) : tree.uncheckNode(node.value))}
/>
<Group gap={5} onClick={() => tree.toggleExpanded(node.value)}>
<Text size="xs">{node.label}</Text>
{hasChildren && (
<IconChevronDown
size={14}
style={{ transform: expanded ? 'rotate(180deg)' : 'rotate(0deg)' }}
/>
)}
</Group>
</Group>
);
};

View File

@ -1,4 +1,3 @@
import { Flex } from '@mantine/core'
import { IObjectData, IObjectType } from '../../interfaces/objects' import { IObjectData, IObjectType } from '../../interfaces/objects'
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
@ -14,11 +13,9 @@ const ObjectData = (object_data: IObjectData) => {
) )
return ( return (
<Flex gap='sm' direction='column'> <div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
{Array.isArray(typeData) && (typeData.find(type => Number(type.id) === Number(object_data.type)) as IObjectType).name} {Array.isArray(typeData) && (typeData.find(type => Number(type.id) === Number(object_data.type)) as IObjectType).name}
</div>
</Flex>
) )
} }

View File

@ -4,6 +4,7 @@ import { BASE_URL } from '../../constants'
import { IObjectParam, IParam } from '../../interfaces/objects' import { IObjectParam, IParam } from '../../interfaces/objects'
import TCBParameter from './TCBParameter' import TCBParameter from './TCBParameter'
import TableValue from './TableValue' import TableValue from './TableValue'
import { TableCell, TableCellLayout, TableRow } from '@fluentui/react-components'
interface ObjectParameterProps { interface ObjectParameterProps {
showLabel?: boolean; showLabel?: boolean;
@ -73,10 +74,19 @@ const ObjectParameter = ({
) )
default: default:
return ( return (
<div> <TableRow>
<TableCell>
<TableCellLayout>
{name} {name}
</TableCellLayout>
</TableCell>
<TableCell>
<TableCellLayout>
{value as string} {value as string}
</div> </TableCellLayout>
</TableCell>
</TableRow>
) )
} }
} }

View File

@ -1,10 +1,10 @@
import { Flex, LoadingOverlay } from '@mantine/core';
import { IObjectParam } from '../../../interfaces/objects'; import { IObjectParam } from '../../../interfaces/objects';
import ObjectParameter from '../ObjectParameter'; import ObjectParameter from '../ObjectParameter';
import useSWR from 'swr'; import useSWR from 'swr';
import { BASE_URL } from '../../../constants'; import { BASE_URL } from '../../../constants';
import { fetcher } from '../../../http/axiosInstance'; import { fetcher } from '../../../http/axiosInstance';
import { useObjectsStore } from '../../../store/objects'; import { useObjectsStore } from '../../../store/objects';
import { Spinner, Table, TableBody } from '@fluentui/react-components';
const ObjectParameters = ({ const ObjectParameters = ({
map_id map_id
@ -24,8 +24,25 @@ const ObjectParameters = ({
) )
return ( return (
<Flex gap={'sm'} direction={'column'} pos='relative'> <div style={{ display: 'flex', gap: '1rem', flexDirection: 'column', position: 'relative' }}>
<LoadingOverlay visible={valuesValidating} /> {(valuesValidating) && (
<div
style={{
position: "absolute",
inset: 0,
backgroundColor: "rgba(255, 255, 255, 0.6)",
display: "flex",
justifyContent: "center",
alignItems: "center",
zIndex: 9999,
}}
>
<Spinner size="large" label="Загрузка..." />
</div>
)}
<Table size='small'>
<TableBody>
{Array.isArray(valuesData) && {Array.isArray(valuesData) &&
Object.entries( Object.entries(
valuesData.reduce((acc, param) => { valuesData.reduce((acc, param) => {
@ -57,7 +74,9 @@ const ObjectParameters = ({
); );
}) })
} }
</Flex> </TableBody>
</Table>
</div>
) )
} }

View File

@ -1,7 +1,6 @@
import useSWR from 'swr' import useSWR from 'swr'
import { BASE_URL } from '../../constants' import { BASE_URL } from '../../constants'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
import { Flex } from '@mantine/core'
const RegionSelect = () => { const RegionSelect = () => {
const { data } = useSWR(`/gis/regions/borders`, (url) => fetcher(url, BASE_URL.ems), { const { data } = useSWR(`/gis/regions/borders`, (url) => fetcher(url, BASE_URL.ems), {
@ -10,7 +9,7 @@ const RegionSelect = () => {
}) })
return ( return (
<Flex align='center' justify='center'> <div style={{ display: 'flex', alignItems: 'center', justifyContent: 'center' }}>
{Array.isArray(data) && {Array.isArray(data) &&
<svg xmlns="http://www.w3.org/2000/svg" fill="none" width='100%' height='100vh' transform='scale(1, -1)'> <svg xmlns="http://www.w3.org/2000/svg" fill="none" width='100%' height='100vh' transform='scale(1, -1)'>
{data.map((el, index) => ( {data.map((el, index) => (
@ -18,7 +17,7 @@ const RegionSelect = () => {
))} ))}
</svg> </svg>
} }
</Flex> </div>
) )
} }

View File

@ -1,8 +1,8 @@
import useSWR from 'swr' import useSWR from 'swr'
import { fetcher } from '../../http/axiosInstance' import { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants' import { BASE_URL } from '../../constants'
import { Text } from '@mantine/core'
import TableValue from './TableValue' import TableValue from './TableValue'
import { Text } from '@fluentui/react-components';
interface ITCBParameterProps { interface ITCBParameterProps {
value: string; value: string;

View File

@ -1,8 +1,8 @@
import { Checkbox, ComboboxData, Grid, NumberInput, Select, Text, Textarea } from '@mantine/core';
import useSWR from 'swr'; import useSWR from 'swr';
import { fetcher } from '../../http/axiosInstance'; import { fetcher } from '../../http/axiosInstance';
import { BASE_URL } from '../../constants'; import { BASE_URL } from '../../constants';
import { useObjectsStore } from '../../store/objects'; import { useObjectsStore } from '../../store/objects';
import { Checkbox, Input, Select, TableCell, TableCellLayout, TableRow, Text } from '@fluentui/react-components';
interface TableValueProps { interface TableValueProps {
name: string; name: string;
@ -30,7 +30,7 @@ const TableValue = ({
return res.map((el) => ({ return res.map((el) => ({
label: el.name || "", label: el.name || "",
value: JSON.stringify(el.id) value: JSON.stringify(el.id)
})) as ComboboxData }))
} }
}), }),
{ {
@ -40,32 +40,52 @@ const TableValue = ({
) )
return ( return (
<Grid> <TableRow>
<Grid.Col span={4} style={{ display: 'flex', alignItems: 'center' }}> <TableCell>
<Text size='xs' style={{ textWrap: 'wrap' }}>{name as string}</Text> <TableCellLayout truncate>
</Grid.Col> <Text size={200} style={{ textWrap: 'wrap' }}>{name as string}</Text>
<Grid.Col span={8}> </TableCellLayout>
</TableCell>
<TableCell>
<div style={{ display: 'flex' }}>
{type === 'boolean' ? {type === 'boolean' ?
// <Select style={{ display: 'flex', width: '100%' }} size='small' defaultChecked={value as boolean}>
// {[true, false].map(tcb => (
// <option key={JSON.stringify(tcb)} value={JSON.stringify(tcb)}>
// {tcb === true ? 'Да' : 'Нет'}
// </option>
// ))}
// </Select>
<Checkbox defaultChecked={value as boolean} /> <Checkbox defaultChecked={value as boolean} />
: :
type === 'number' ? type === 'number' ?
<NumberInput <Input
size='xs' size='small'
value={value as number} style={{ display: 'flex', width: '100%' }}
defaultValue={value as string}
onChange={() => { }} onChange={() => { }}
suffix={unit ? ` ${unit}` : ''} contentAfter={unit ? ` ${unit}` : ''}
//displayValue={unit ? ` ${unit}` : ''}
/> />
: :
type === 'select' && !isValidating && tcbAll ? type === 'select' && !isValidating && tcbAll ?
<Select size='xs' data={tcbAll} value={JSON.stringify(value)} /> <Select style={{ display: 'flex', width: '100%' }} size='small' defaultValue={JSON.stringify(value)}>
{tcbAll.map(tcb => (
<option key={tcb.value} value={tcb.value}>
{tcb.label}
</option>
))}
</Select>
: :
type === 'string' ? type === 'string' ?
<Textarea size='xs' value={value as string} autosize minRows={1} /> <Input style={{ display: 'flex', width: '100%' }} size='small' value={value as string} />
: :
<Text size='xs'>{value as string}</Text> <Text size={200}>{value as string}</Text>
} }
</Grid.Col> </div>
</Grid> </TableCell>
</TableRow>
) )
} }

View File

@ -1,4 +1,5 @@
import { ScrollAreaAutosize, Tabs } from '@mantine/core'; import { Tab, TabList } from '@fluentui/react-components';
import { useState } from 'react';
export interface ITabsPane { export interface ITabsPane {
title: string; title: string;
@ -15,30 +16,39 @@ const TabsPane = ({
defaultTab, defaultTab,
tabs tabs
}: TabsPaneProps) => { }: TabsPaneProps) => {
const [selectedTab, setSelectedTab] = useState<string | unknown>(defaultTab)
return ( return (
<Tabs defaultValue={defaultTab} mah='50%' h={'100%'} style={{ <div style={{
display: 'grid', display: 'flex',
gridTemplateRows: 'min-content auto' flexDirection: 'column',
width: '100%',
height: '100%',
maxHeight: '50%',
}}> }}>
<ScrollAreaAutosize> <div style={{
<Tabs.List> display: 'flex',
flexWrap: 'wrap',
maxWidth: '100%',
overflowX: 'auto',
minHeight: 'min-content',
borderBottom: '1px solid var(--colorNeutralShadowKey)'
}}>
<TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
{tabs.map((tab) => ( {tabs.map((tab) => (
<Tabs.Tab key={tab.value} value={tab.value}> <Tab value={tab.value}>{tab.title}</Tab>
{tab.title}
</Tabs.Tab>
))} ))}
</Tabs.List> </TabList>
</ScrollAreaAutosize> </div>
<ScrollAreaAutosize h='100%' offsetScrollbars> <div style={{
{tabs.map(tab => ( display: 'flex',
<Tabs.Panel p='xs' key={tab.value} value={tab.value}> overflow: 'auto'
{tab.view} }}>
</Tabs.Panel> {tabs.find(tab => tab.value === selectedTab)?.view}
))} </div>
</ScrollAreaAutosize> </div>
</Tabs>
) )
} }

View File

@ -9,8 +9,8 @@ import '@js-preview/docx/lib/index.css'
import jsPreviewPdf from '@js-preview/pdf' import jsPreviewPdf from '@js-preview/pdf'
import { IDocument } from '../../interfaces/documents'; import { IDocument } from '../../interfaces/documents';
import { IconAlertTriangle, IconChevronLeft, IconChevronRight } from '@tabler/icons-react'; import { IconAlertTriangle, IconChevronLeft, IconChevronRight, IconX } from '@tabler/icons-react';
import { Button, Flex, Grid, Loader, Modal, ScrollAreaAutosize, Text } from '@mantine/core'; import { Button, Spinner, Text } from '@fluentui/react-components';
interface Props { interface Props {
open: boolean; open: boolean;
@ -112,7 +112,7 @@ function ImageViewer({
url url
}: ViewerProps) { }: ViewerProps) {
return ( return (
<Flex style={{ <div style={{
display: 'flex', display: 'flex',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
@ -125,7 +125,7 @@ function ImageViewer({
maxWidth: '100%', maxWidth: '100%',
maxHeight: '100%' maxHeight: '100%'
}} /> }} />
</Flex> </div>
) )
} }
@ -151,71 +151,71 @@ export default function FileViewer({
} }
return ( return (
<Modal.Root fullScreen opened={open} onClose={() => setOpen(false)} scrollAreaComponent={ScrollAreaAutosize.Autosize}> <div style={{display: open ? 'flex' : 'none'}}>
<Modal.Overlay /> <div style={{
<Modal.Content style={{
display: 'grid', display: 'grid',
position: 'fixed',
inset: 0,
zIndex: '9999',
background: 'var(--colorNeutralBackground1)',
gridTemplateRows: 'min-content auto', gridTemplateRows: 'min-content auto',
width: '100vw', width: '100vw',
height: '100vh' height: '100vh'
}}> }}>
<Modal.Header> <div style={{ display: 'flex', padding: '1rem' }}>
<Modal.Title component='div' w='100%'> <div style={{ width: '100%' }}>
<Flex align='center'> <div style={{ display: 'flex', alignItems: 'center' }}>
<Text mr='auto'>{currentFileNo != -1 && docs[currentFileNo].name}</Text> <Text style={{ marginRight: 'auto' }}>
{currentFileNo != -1 && docs[currentFileNo].name}
</Text>
<Grid> <div>
<Grid.Col span='auto'>
<Button <Button
variant='transparent' icon={<IconChevronLeft />}
appearance='transparent'
onClick={() => { onClick={() => {
if (currentFileNo >= 0 && currentFileNo > 0) { if (currentFileNo >= 0 && currentFileNo > 0) {
setCurrentFileNo(currentFileNo - 1) setCurrentFileNo(currentFileNo - 1)
} }
}} }}
disabled={currentFileNo >= 0 && currentFileNo === 0} disabled={currentFileNo >= 0 && currentFileNo === 0}
> />
<IconChevronLeft />
</Button>
</Grid.Col>
<Grid.Col span='auto'>
<Button <Button
variant='transparent' icon={<IconChevronRight />}
appearance='transparent'
onClick={() => { onClick={() => {
if (currentFileNo >= 0 && currentFileNo < docs.length) { if (currentFileNo >= 0 && currentFileNo < docs.length) {
setCurrentFileNo(currentFileNo + 1) setCurrentFileNo(currentFileNo + 1)
} }
}} }}
disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1} disabled={currentFileNo >= 0 && currentFileNo >= docs.length - 1}
> />
<IconChevronRight /> </div>
</Button>
</Grid.Col>
</Grid>
<Button <Button
autoFocus autoFocus
variant='subtle' appearance='subtle'
onClick={handleSave} onClick={handleSave}
> >
Сохранить Сохранить
</Button> </Button>
</Flex> </div>
</Modal.Title> </div>
<Modal.CloseButton ml='xl' />
</Modal.Header> <Button icon={<IconX />} appearance='subtle' onClick={() => setOpen(false)} />
<Modal.Body style={{ display: 'flex', flexGrow: 1, height: '100%', width: '100vw' }}> </div>
<div style={{ display: 'flex', flexGrow: 1, height: '100%', overflow: 'auto', width: '100vw' }}>
{fileIsLoading || fileTypeIsLoading ? {fileIsLoading || fileTypeIsLoading ?
<Flex style={{ <div style={{
display: 'flex', display: 'flex',
width: '100%', width: '100%',
height: '100%', height: '100%',
alignItems: 'center', alignItems: 'center',
justifyContent: 'center' justifyContent: 'center'
}}> }}>
<Loader /> <Spinner />
</Flex> </div>
: :
fileType === 'application/pdf' ? fileType === 'application/pdf' ?
<PdfViewer url={window.URL.createObjectURL(file)} /> <PdfViewer url={window.URL.createObjectURL(file)} />
@ -230,27 +230,27 @@ export default function FileViewer({
<ImageViewer url={window.URL.createObjectURL(file)} /> <ImageViewer url={window.URL.createObjectURL(file)} />
: :
fileType && file ? fileType && file ?
<Flex style={{ display: 'flex', gap: '16px', flexDirection: 'column', p: '16px' }}> <div style={{ display: 'flex', gap: '16px', flexDirection: 'column', padding: '1rem' }}>
<Flex style={{ display: 'flex', gap: '16px', alignItems: 'center' }}> <div style={{ display: 'flex', gap: '16px', alignItems: 'center' }}>
<IconAlertTriangle /> <IconAlertTriangle />
<Text> <Text>
Предпросмотр данного файла невозможен. Предпросмотр данного файла невозможен.
</Text> </Text>
</Flex> </div>
<Flex> <div style={{ display: 'flex' }}>
<Button variant='contained' onClick={() => { <Button appearance='secondary' onClick={() => {
handleSave() handleSave()
}}> }}>
Сохранить Сохранить
</Button> </Button>
</Flex> </div>
</Flex> </div>
: :
null null
} }
</Modal.Body> </div>
</Modal.Content> </div>
</Modal.Root> </div>
) )
} }

View File

@ -1,4 +1,4 @@
import { IconBuildingFactory2, IconComponents, IconDeviceDesktopAnalytics, IconFiles, IconFlame, IconHome, IconLogin, IconLogin2, IconMap, IconPassword, IconReport, icons, IconServer, IconSettings, IconShield, IconUsers } from "@tabler/icons-react"; import { IconComponents, IconDeviceDesktopAnalytics, IconFlame, IconLogin, IconLogin2, IconPassword, IconSettings } from "@tabler/icons-react";
import SignIn from "../pages/auth/SignIn"; import SignIn from "../pages/auth/SignIn";
import SignUp from "../pages/auth/SignUp"; import SignUp from "../pages/auth/SignUp";
import PasswordReset from "../pages/auth/PasswordReset"; import PasswordReset from "../pages/auth/PasswordReset";
@ -17,6 +17,7 @@ import PrintReport from "../pages/PrintReport";
import DBManager from "../pages/DBManager"; import DBManager from "../pages/DBManager";
import MapLineTest from "../components/map/MapLineTest"; import MapLineTest from "../components/map/MapLineTest";
import FuelPage from "../pages/Fuel"; import FuelPage from "../pages/Fuel";
import { Building24Color, Cloud24Color, Database24Color, Document24Color, Form24Color, Home24Color, Map24Filled, Map24Regular, PeopleList24Color, Shield24Color } from "@fluentui/react-icons"
// Определение страниц с путями и компонентом для рендера // Определение страниц с путями и компонентом для рендера
@ -60,7 +61,7 @@ const pages = [
{ {
label: "Главная", label: "Главная",
path: "/", path: "/",
icon: <IconHome />, icon: <Home24Color />,
component: <Main />, component: <Main />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -69,7 +70,7 @@ const pages = [
{ {
label: "Пользователи", label: "Пользователи",
path: "/user", path: "/user",
icon: <IconUsers />, icon: <PeopleList24Color />,
component: <Users />, component: <Users />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -78,7 +79,7 @@ const pages = [
{ {
label: "Роли", label: "Роли",
path: "/role", path: "/role",
icon: <IconShield />, icon: <Shield24Color />,
component: <Roles />, component: <Roles />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -87,7 +88,7 @@ const pages = [
{ {
label: "Документы", label: "Документы",
path: "/documents", path: "/documents",
icon: <IconFiles />, icon: <Document24Color />,
component: <Documents />, component: <Documents />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -96,7 +97,7 @@ const pages = [
{ {
label: "Отчеты", label: "Отчеты",
path: "/reports", path: "/reports",
icon: <IconReport />, icon: <Form24Color />,
component: <Reports />, component: <Reports />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -105,7 +106,7 @@ const pages = [
{ {
label: "Серверы", label: "Серверы",
path: "/servers", path: "/servers",
icon: <IconServer />, icon: <Cloud24Color />,
component: <Servers />, component: <Servers />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -114,7 +115,7 @@ const pages = [
{ {
label: "Котельные", label: "Котельные",
path: "/boilers", path: "/boilers",
icon: <IconBuildingFactory2 />, icon: <Building24Color />,
component: <Boilers />, component: <Boilers />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -123,7 +124,7 @@ const pages = [
{ {
label: "ИКС", label: "ИКС",
path: "/map-test", path: "/map-test",
icon: <IconMap />, icon: <Map24Filled />,
component: <MapTest />, component: <MapTest />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -132,7 +133,7 @@ const pages = [
{ {
label: "Map line test", label: "Map line test",
path: "/map-line-test", path: "/map-line-test",
icon: <IconMap />, icon: <Map24Regular />,
component: <MapLineTest />, component: <MapLineTest />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,
@ -168,7 +169,7 @@ const pages = [
{ {
label: "Тест БД", label: "Тест БД",
path: "/db-manager", path: "/db-manager",
icon: <IconComponents />, icon: <Database24Color />,
component: <DBManager />, component: <DBManager />,
drawer: true, drawer: true,
dashboard: true, dashboard: true,

View File

@ -0,0 +1,30 @@
import * as React from "react";
import { webLightTheme, webDarkTheme, Theme } from "@fluentui/react-components";
type ColorScheme = "light" | "dark";
const STORAGE_KEY = "color-scheme";
export function useFluentColorScheme(): {
colorScheme: ColorScheme;
setColorScheme: (value: ColorScheme) => void;
theme: Theme;
} {
const [colorScheme, setColorSchemeState] = React.useState<ColorScheme>(() => {
if (typeof window !== "undefined") {
const saved = localStorage.getItem(STORAGE_KEY) as ColorScheme | null;
if (saved === "light" || saved === "dark") return saved;
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
return prefersDark ? "dark" : "light";
}
return "light";
});
const setColorScheme = React.useCallback((value: ColorScheme) => {
setColorSchemeState(value);
localStorage.setItem(STORAGE_KEY, value);
}, []);
const theme = colorScheme === "dark" ? webDarkTheme : webLightTheme;
return { colorScheme, setColorScheme, theme };
}

View File

@ -0,0 +1,11 @@
export type IColumnType = "string" | "number" | "boolean" | "dictionary"
export interface IColumn {
name: string
header: string
type: IColumnType
}
export interface IColumnsDefinition {
}

View File

@ -0,0 +1,4 @@
export interface IDictionary {
id: number
name: string
}

View File

@ -1,15 +1,53 @@
import { AppShell, Avatar, Burger, Button, Flex, Group, Image, Menu, NavLink, rem, Text, useMantineColorScheme } from '@mantine/core'; import { useMantineColorScheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useNavigate } from 'react-router-dom'; import { Outlet, useNavigate } from 'react-router-dom';
import { IconChevronDown, IconLogout, IconSettings, IconMoon, IconSun } from '@tabler/icons-react'; import { IconLogout, IconSettings, IconMoon, IconSun, IconMenu2, IconUser } from '@tabler/icons-react';
import { getUserData, logout, useAuthStore } from '../store/auth'; import { getUserData, logout, useAuthStore } from '../store/auth';
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { UserData } from '../interfaces/auth'; import { UserData } from '../interfaces/auth';
import { pages } from '../constants/app'; import { pages } from '../constants/app';
import { Button, Image, makeStyles, Menu, MenuButton, MenuItem, MenuList, MenuPopover, MenuTrigger, Text } from '@fluentui/react-components';
const useStyles = makeStyles({
root: {
display: 'grid',
gridTemplateRows: 'min-content auto',
height: '100vh',
maxHeight: '100vh',
overflow: 'hidden'
},
header: {
display: 'flex',
maxHeight: '3rem',
borderBottom: '1px solid var(--colorNeutralShadowKey)'
},
main: {
display: 'flex',
overflow: 'hidden',
width: '100%',
height: '100%',
},
navbar: {
overflow: 'auto',
display: 'flex',
flexDirection: 'column',
maxWidth: '200px',
position: 'relative',
width: '100%',
height: '100%',
transition: 'max-width .2s ease-in-out',
borderRight: '1px solid var(--colorNeutralShadowKey)'
},
content: {
overflow: 'auto',
display: 'flex',
position: 'relative',
width: '100%',
height: '100%'
}
})
function DashboardLayout() { function DashboardLayout() {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(false)
const navigate = useNavigate() const navigate = useNavigate()
const getPageTitle = () => { const getPageTitle = () => {
@ -33,115 +71,201 @@ function DashboardLayout() {
const { colorScheme, setColorScheme } = useMantineColorScheme(); const { colorScheme, setColorScheme } = useMantineColorScheme();
const classes = useStyles()
const [navbarOpen, setNavbarOpen] = useState(true)
return ( return (
<AppShell <div className={classes.root}>
header={{ height: 60 }} <div className={classes.header}>
navbar={{ <div style={{
width: desktopOpened ? 200 : 50, display: 'flex',
breakpoint: 'sm', height: '100%',
collapsed: { mobile: !mobileOpened }, width: '100%',
}} alignItems: 'center',
> gap: '0.75rem',
<AppShell.Header> padding: '0.5rem 0.5rem 0.5rem 0.25rem',
<Flex h="100%" px="md" w='100%' align='center' gap='sm'> }}>
<Group> <Button appearance='subtle' onClick={() => setNavbarOpen(!navbarOpen)} icon={<IconMenu2 />} />
<Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
<Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
</Group>
<Group w='auto'> <Text weight='bold' size={400}>
<Text fw='600'>{getPageTitle()}</Text> {getPageTitle()}
</Group>
<Group id='header-portal' w='auto' ml='auto'>
</Group>
<Group style={{ flexShrink: 0 }}>
<Menu
width={260}
position="bottom-end"
transitionProps={{ transition: 'pop-top-right' }}
withinPortal
>
<Menu.Target>
<Button variant='transparent'>
<Group gap={7}>
<Avatar name={`${userData?.name} ${userData?.surname}`} radius="xl" size={30} />
<Text fw={500} size="sm" lh={1} mr={3}>
{`${userData?.name} ${userData?.surname}`}
</Text> </Text>
<IconChevronDown style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
</Group> <div id='header-portal' style={{ marginLeft: 'auto' }}>
</Button>
</Menu.Target> </div>
<Menu.Dropdown>
<Menu.Label>{userData?.login}</Menu.Label> <div style={{ flexShrink: 0 }}>
<Menu.Item <Menu positioning={{ autoSize: true }}>
leftSection={ <MenuTrigger>
colorScheme === 'dark' ? <IconMoon style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> : <IconSun style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> <MenuButton appearance='transparent' icon={<IconUser />}>{`${userData?.name} ${userData?.surname}`}</MenuButton>
} </MenuTrigger>
onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}
> <MenuPopover>
Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'} <MenuList>
</Menu.Item> <MenuItem icon={
<Menu.Item colorScheme === 'dark' ? <IconMoon /> : <IconSun />
leftSection={ } onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}>Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}</MenuItem>
<IconSettings style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> <MenuItem icon={<IconSettings />} onClick={() => navigate('/settings')}>Настройки профиля</MenuItem>
} <MenuItem icon={<IconLogout />} onClick={() => {
onClick={() => navigate('/settings')}
>
Настройки профиля
</Menu.Item>
<Menu.Item
onClick={() => {
logout() logout()
navigate("/auth/signin") navigate("/auth/signin")
}} }}>Выход</MenuItem>
leftSection={<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />} <MenuItem icon={<Image src={'/logo2.svg'} width={24} />}>
> 0.1.0
Выход </MenuItem>
</Menu.Item> </MenuList>
</MenuPopover>
<Menu.Item>
<Flex gap='sm' align='center'>
<Image src={'/logo2.svg'} w={32} />
<Text>0.1.0</Text>
</Flex>
</Menu.Item>
</Menu.Dropdown>
</Menu> </Menu>
</Group> </div>
</Flex> </div>
</AppShell.Header> </div>
<AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
<div className={classes.main}>
<div className={classes.navbar} style={{
maxWidth: navbarOpen ? '200px' : '2.70rem',
}}>
{pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => ( {pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => (
<NavLink <Button key={item.path} style={{ paddingLeft: '0.5rem', flexShrink: 0, flexWrap: 'nowrap', textWrap: 'nowrap', borderRadius: 0 }} appearance='subtle' onClick={() => navigate(item.path)}>
key={item.path} <div style={{ display: 'flex', }}>
onClick={() => navigate(item.path)} {item.icon}
label={item.label} </div>
leftSection={item.icon}
active={location.pathname === item.path} <div style={{
style={{ textWrap: 'nowrap' }} display: 'flex',
// styles={(theme, { active }) => ({ justifyContent: 'flex-start',
// root: { width: '100%',
// color: active ? theme.colors.blue[6] : theme.colors.dark[5], overflow: 'hidden',
// fontWeight: active ? "bold" : "normal", marginLeft: '1rem',
// }, }}>
// leftSection: { {item.label}
// color: active ? theme.colors.blue[6] : theme.colors.dark[5], // Icon color </div>
// }
// })} </Button>
/> // <NavItem style={{ flexShrink: 0, flexWrap: 'nowrap', textWrap: 'nowrap' }} onClick={() => navigate(item.path)} icon={item.icon} value={item.path}>
// {item.label}
// </NavItem>
))} ))}
</AppShell.Navbar> </div>
<AppShell.Main>
<Flex bg={colorScheme === 'dark' ? undefined : '#E8E8E8'} w={{ sm: desktopOpened ? 'calc(100% - 200px)' : 'calc(100% - 50px)', base: '100%' }} h={'calc(100% - 60px)'} style={{ transition: "width 0.2s ease" }} pos={'fixed'}> <div className={classes.content}>
<Outlet /> <Outlet />
</Flex> </div>
</AppShell.Main> </div>
</AppShell>
</div>
) )
// return (
// <AppShell
// header={{ height: 60 }}
// navbar={{
// width: desktopOpened ? 200 : 50,
// breakpoint: 'sm',
// collapsed: { mobile: !mobileOpened },
// }}
// >
// <AppShell.Header>
// <Flex h="100%" px="md" w='100%' align='center' gap='sm'>
// <Group>
// <Burger opened={mobileOpened} onClick={toggleMobile} hiddenFrom="sm" size="sm" />
// <Burger opened={desktopOpened} onClick={toggleDesktop} visibleFrom="sm" size="sm" />
// </Group>
// <Group w='auto'>
// <Text fw='600'>{getPageTitle()}</Text>
// </Group>
// <Group id='header-portal' w='auto' ml='auto'>
// </Group>
// <Group style={{ flexShrink: 0 }}>
// <Menu
// width={260}
// position="bottom-end"
// transitionProps={{ transition: 'pop-top-right' }}
// withinPortal
// >
// <Menu.Target>
// <Button variant='transparent'>
// <Group gap={7}>
// <Avatar name={`${userData?.name} ${userData?.surname}`} radius="xl" size={30} />
// <Text fw={500} size="sm" lh={1} mr={3}>
// {`${userData?.name} ${userData?.surname}`}
// </Text>
// <IconChevronDown style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
// </Group>
// </Button>
// </Menu.Target>
// <Menu.Dropdown>
// <Menu.Label>{userData?.login}</Menu.Label>
// <Menu.Item
// leftSection={
// colorScheme === 'dark' ? <IconMoon style={{ width: rem(16), height: rem(16) }} stroke={1.5} /> : <IconSun style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
// }
// onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}
// >
// Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}
// </Menu.Item>
// <Menu.Item
// leftSection={
// <IconSettings style={{ width: rem(16), height: rem(16) }} stroke={1.5} />
// }
// onClick={() => navigate('/settings')}
// >
// Настройки профиля
// </Menu.Item>
// <Menu.Item
// onClick={() => {
// logout()
// navigate("/auth/signin")
// }}
// leftSection={<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />}
// >
// Выход
// </Menu.Item>
// <Menu.Item>
// <Flex gap='sm' align='center'>
// <Image src={'/logo2.svg'} w={32} />
// <Text>0.1.0</Text>
// </Flex>
// </Menu.Item>
// </Menu.Dropdown>
// </Menu>
// </Group>
// </Flex>
// </AppShell.Header>
// <AppShell.Navbar style={{ transition: "width 0.2s ease" }}>
// {pages.filter((page) => page.drawer).filter((page) => page.enabled).map((item) => (
// <NavLink
// key={item.path}
// onClick={() => navigate(item.path)}
// label={item.label}
// leftSection={item.icon}
// active={location.pathname === item.path}
// style={{ textWrap: 'nowrap' }}
// // styles={(theme, { active }) => ({
// // root: {
// // color: active ? theme.colors.blue[6] : theme.colors.dark[5],
// // fontWeight: active ? "bold" : "normal",
// // },
// // leftSection: {
// // color: active ? theme.colors.blue[6] : theme.colors.dark[5], // Icon color
// // }
// // })}
// />
// ))}
// </AppShell.Navbar>
// <AppShell.Main>
// <Flex bg={colorScheme === 'dark' ? undefined : '#E8E8E8'} w={{ sm: desktopOpened ? 'calc(100% - 200px)' : 'calc(100% - 50px)', base: '100%' }} h={'calc(100% - 60px)'} style={{ transition: "width 0.2s ease" }} pos={'fixed'}>
// <Outlet />
// </Flex>
// </AppShell.Main>
// </AppShell>
// )
} }
export default DashboardLayout export default DashboardLayout

View File

@ -1,10 +1,21 @@
import { Flex } from "@mantine/core"; import { makeStyles } from "@fluentui/react-components";
import { Outlet } from "react-router-dom"; import { Outlet } from "react-router-dom";
const useStyles = makeStyles({
root: {
display: 'flex',
justifyContent: 'center',
height: '100%',
width: '100%'
}
})
export default function MainLayout() { export default function MainLayout() {
const classes = useStyles()
return ( return (
<Flex align='center' justify='center' h='100%' w='100%'> <div className={classes.root}>
<Outlet /> <Outlet />
</Flex> </div>
) )
} }

View File

@ -8,6 +8,7 @@ import './index.css'
import { createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme } from '@mantine/core'; import { createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme } from '@mantine/core';
import 'dayjs/locale/ru'; import 'dayjs/locale/ru';
import { DatesProvider } from "@mantine/dates"; import { DatesProvider } from "@mantine/dates";
import { FluentProvider, webLightTheme } from '@fluentui/react-components';
const overrides = createTheme({ const overrides = createTheme({
// Set this color to `--mantine-color-body` CSS variable // Set this color to `--mantine-color-body` CSS variable
@ -22,9 +23,22 @@ const theme = mergeMantineTheme(DEFAULT_THEME, overrides);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render( ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode> <React.StrictMode>
<MantineProvider theme={theme}> <MantineProvider theme={theme}>
<FluentProvider theme={webLightTheme}>
<DatesProvider settings={{ locale: 'ru' }}> <DatesProvider settings={{ locale: 'ru' }}>
<App /> <App />
</DatesProvider> </DatesProvider>
</FluentProvider>
</MantineProvider> </MantineProvider>
</React.StrictMode>, </React.StrictMode>,
) )
// ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
// <React.StrictMode>
// <MantineProvider theme={theme}>
// <DatesProvider settings={{ locale: 'ru' }}>
// <App />
// </DatesProvider>
// </MantineProvider>
// </React.StrictMode>,
// )

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { useBoilers } from '../hooks/swrHooks' import { useBoilers } from '../hooks/swrHooks'
import { Stack, Text } from '@mantine/core'
import CustomTable from '../components/CustomTable' import CustomTable from '../components/CustomTable'
import { Text } from '@fluentui/react-components'
function Boilers() { function Boilers() {
const [boilersPage, setBoilersPage] = useState(1) const [boilersPage, setBoilersPage] = useState(1)
@ -25,82 +25,47 @@ function Boilers() {
}, []) }, [])
return ( return (
<Stack w={'100%'} h={'100%'} p='sm'> <div style={{
<Text size="xl" fw={600}> display: 'flex',
flexDirection: 'column',
padding: '1rem',
width: '100%',
gap: '1rem'
}}>
<Text size={600} weight='bold'>
Котельные Котельные
</Text> </Text>
{boilers && {boilers &&
<CustomTable data={boilers} columns={[ <CustomTable data={boilers} columns={[
{ {
accessorKey: 'id_object', name: 'id_object',
header: 'ID', header: 'ID',
cell: (info) => info.getValue(), type: 'string'
}, },
{ {
accessorKey: 'boiler_name', name: 'boiler_name',
header: 'Название', header: 'Название',
cell: (info) => info.getValue(), type: 'string'
}, },
{ {
accessorKey: 'boiler_code', name: 'boiler_code',
header: 'Код', header: 'Код',
cell: (info) => info.getValue(), type: 'string'
}, },
{ {
accessorKey: 'id_city', name: 'id_city',
header: 'Город', header: 'Город',
cell: (info) => info.getValue(), type: 'dictionary'
}, },
{ {
accessorKey: 'activity', name: 'activity',
header: 'Активен', header: 'Активен',
cell: (info) => info.getValue(), type: 'boolean'
}, },
]} /> ]} />
} }
</div>
{/* {boilers &&
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{boilersColumns.map(column => (
<Table.Th key={column.field}>{column.headerName}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{boilers.map((boiler: IBoiler) => (
<Table.Tr key={boiler.id_object}>
{boilersColumns.map(column => {
if (column.field === 'activity') {
return (
boiler.activity ? (
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
<Badge fullWidth variant="light">
Активен
</Badge>
</Table.Td>
) : (
<Table.Td key={`${boiler.id_object}-${boiler[column.field]}`}>
<Badge color="gray" fullWidth variant="light">
Отключен
</Badge>
</Table.Td>
)
)
}
else return (
<Table.Td key={`${boiler.id_object}-${column.field}`}>{boiler[column.field as keyof IBoiler]}</Table.Td>
)
})}
</Table.Tr>
))}
</Table.Tbody>
</Table>
} */}
</Stack>
) )
} }

View File

@ -1,11 +1,10 @@
import { Flex } from '@mantine/core'
import ServerHardware from '../components/ServerHardware' import ServerHardware from '../components/ServerHardware'
const ComponentTest = () => { const ComponentTest = () => {
return ( return (
<Flex direction='column' align='flex-start' gap='sm' p='sm'> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'flex-start', gap: '1rem', padding: '1rem' }}>
<ServerHardware /> <ServerHardware />
</Flex> </div>
) )
} }

View File

@ -1,36 +1,41 @@
import { Stack, Tabs } from '@mantine/core'
import useSWR from 'swr' import useSWR from 'swr'
import { BASE_URL } from '../constants' import { BASE_URL } from '../constants'
import { fetcher } from '../http/axiosInstance' import { fetcher } from '../http/axiosInstance'
import { useState } from 'react' import { useState } from 'react'
import CustomTable from '../components/CustomTable' import CustomTable from '../components/CustomTable'
import { Tab, TabList } from '@fluentui/react-components'
const DBManager = () => { const DBManager = () => {
const { data: tablesData } = useSWR(`/db/tables`, (key) => fetcher(key, BASE_URL.ems), { const { data: tablesData } = useSWR(`/db/tables`, (key) => fetcher(key, BASE_URL.ems), {
revalidateOnFocus: false revalidateOnFocus: false
}) })
const [selectedTab, setSelectedTab] = useState<string | unknown>(undefined)
return ( return (
<Stack w={'100%'} h={'100%'} p='xs'> <div style={{ display: 'flex', flexDirection: 'column', width: '100%', height: '100%', padding: '0.5rem' }}>
{tablesData && Array.isArray(tablesData) && tablesData.length > 0 && {tablesData && Array.isArray(tablesData) && tablesData.length > 0 &&
<Tabs w='100%' h='80%'> <div style={{ width: '100%', height: '100%' }}>
<Stack h='100%'> <div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<Tabs.List> <TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
{tablesData.map(table => ( {tablesData.map(table => (
<Tabs.Tab key={table.tablename} value={table.tablename}> <Tab key={table.tablename} value={table.tablename}>
{table.tablename} {table.tablename}
</Tabs.Tab> </Tab>
))} ))}
</Tabs.List> </TabList>
{tablesData.map(table => ( <div style={{ width: '100%', height: '100%' }}>
<Tabs.Panel h='100%' key={table.tablename} value={table.tablename} w='100%'> {tablesData.map((table) => {
if (table.tablename === selectedTab)
return (
<TableData tablename={table.tablename} /> <TableData tablename={table.tablename} />
</Tabs.Panel> )
))} })}
</Stack> </div>
</div>
</Tabs> </div>
} }
{/* <Card withBorder radius='sm'> {/* <Card withBorder radius='sm'>
<Stack> <Stack>
@ -42,7 +47,7 @@ const DBManager = () => {
</Grid> </Grid>
</Stack> </Stack>
</Card> */} </Card> */}
</Stack> </div>
) )
} }
@ -65,9 +70,9 @@ const TableData = ({
{columnsData && rowsData && Array.isArray(columnsData) && Array.isArray(rowsData) && columnsData.length > 0 && {columnsData && rowsData && Array.isArray(columnsData) && Array.isArray(rowsData) && columnsData.length > 0 &&
<CustomTable data={rowsData} columns={columnsData.map(column => ( <CustomTable data={rowsData} columns={columnsData.map(column => (
{ {
accessorKey: column.column_name, name: column.column_name,
header: column.column_name, header: column.column_name,
cell: (info) => JSON.stringify(info.getValue()).length > 30 ? [JSON.stringify(info.getValue()).substring(0, 30), '...'].join('') : JSON.stringify(info.getValue()), type: 'string',
} }
))} /> ))} />
} }

View File

@ -1,15 +1,15 @@
import { ActionIcon, Button, Flex, Input, Loader, LoadingOverlay, Modal, Overlay, Table, Tabs, TextInput, useMantineColorScheme } from "@mantine/core"; import { Modal, useMantineColorScheme } from "@mantine/core";
import { IconMathMax, IconPlus, IconTableMinus, IconTablePlus } from "@tabler/icons-react"; import { IconMathMax, IconPlus, IconTableMinus } from "@tabler/icons-react";
import { FuelExpenseDto, FuelExpenseDtoHeaders, FuelLimitDto, FuelLimitDtoHeaders } from "../dto/fuel/fuel.dto"; import { FuelExpenseDtoHeaders, FuelLimitDtoHeaders } from "../dto/fuel/fuel.dto";
import useSWR from "swr"; import useSWR from "swr";
import { fetcher } from "../http/axiosInstanceNest"; import { fetcher } from "../http/axiosInstanceNest";
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
import { useDisclosure } from "@mantine/hooks"; import { DateInput } from '@mantine/dates'
import { DateInput, DatePicker } from '@mantine/dates'
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { AgGridReact } from "ag-grid-react"; import { AgGridReact } from "ag-grid-react";
import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community' import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community'
import { Button, Field, Input, Spinner, Tab, TabList } from "@fluentui/react-components";
ModuleRegistry.registerModules([AllCommunityModule]) ModuleRegistry.registerModules([AllCommunityModule])
@ -92,9 +92,9 @@ export default function FuelPage() {
const [currentTab, setCurrentTab] = useState(tables[0]) const [currentTab, setCurrentTab] = useState(tables[0])
const { data, isLoading } = useSWR(currentTab.get, () => fetcher(currentTab.get), { revalidateOnFocus: false }) const { isLoading } = useSWR(currentTab.get, () => fetcher(currentTab.get), { revalidateOnFocus: false })
const [openedCreateModal, { open: openCreateModal, close: closeCreateModal }] = useDisclosure(false) const [openCreateModel, setOpenCreateModal] = useState(false)
const { colorScheme } = useMantineColorScheme() const { colorScheme } = useMantineColorScheme()
@ -108,32 +108,34 @@ export default function FuelPage() {
return ( return (
<> <>
<ModalCreate openedCreateModal={openedCreateModal} closeCreateModal={closeCreateModal} currentTab={currentTab} /> <ModalCreate openedCreateModal={openCreateModel} closeCreateModal={() => setOpenCreateModal(false)} currentTab={currentTab} />
<Tabs defaultValue={tables[0].value} w='100%' onChange={(tab) => setCurrentTab(tables.find(table => table.value === tab) || tables[0])}> <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<Tabs.List> <TabList defaultValue={tables[0].value} selectedValue={currentTab.value}>
{tables.map((table, index) => ( {tables.map((table, index) => (
<Tabs.Tab key={index} value={table.value} leftSection={table.icon}> <Tab key={index} value={table.value} icon={table.icon} onClick={() => setCurrentTab(table)}>
{table.label} {table.label}
</Tabs.Tab> </Tab>
))} ))}
</Tabs.List> </TabList>
<Flex p='sm'> <div style={{ display: 'flex', padding: '1rem' }}>
<Button leftSection={<IconPlus />} onClick={openCreateModal}> <Button appearance='primary' icon={<IconPlus />} onClick={() => setOpenCreateModal(true)}>
Добавить Добавить
</Button> </Button>
</Flex> </div>
{tables.map((table, index) => ( {tables.map((table, index) => {
<Tabs.Panel key={index} value={table.value} w='100%' h='100%'> if (table.value === currentTab.value) {
{isLoading ? return (
<Flex w='100%' justify={'center'} p='md'> isLoading ?
<Loader /> <div style={{ display: 'flex', width: '100%', justifyContent: 'center', padding: '1rem' }}>
</Flex> <Spinner />
</div>
: :
<> <>
<AgGridReact <AgGridReact
key={index}
//rowData={data} //rowData={data}
rowData={[ rowData={[
Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {}), Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {}),
@ -147,10 +149,11 @@ export default function FuelPage() {
}} }}
/> />
</> </>
)
} }
</Tabs.Panel> }
))} )}
</Tabs> </div>
</> </>
) )
} }
@ -164,7 +167,9 @@ const ModalCreate = ({
closeCreateModal: () => void closeCreateModal: () => void
currentTab: ITableSchema currentTab: ITableSchema
}) => { }) => {
const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting, dirtyFields, isValid } } = useForm({ const { register, handleSubmit,
//formState: { errors, isSubmitting, dirtyFields, isValid }
} = useForm({
mode: 'onChange', mode: 'onChange',
}) })
@ -174,9 +179,7 @@ const ModalCreate = ({
return ( return (
<Modal withinPortal opened={openedCreateModal} onClose={closeCreateModal}> <Modal withinPortal opened={openedCreateModal} onClose={closeCreateModal}>
<LoadingOverlay visible={isSubmitting} /> <form style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} onSubmit={handleSubmit(onSubmit)}>
<Flex direction='column' gap='sm' component='form' onSubmit={handleSubmit(onSubmit)}>
{currentTab.post_include.map((header, index) => { {currentTab.post_include.map((header, index) => {
switch (header.field_type) { switch (header.field_type) {
case 'date': case 'date':
@ -185,23 +188,27 @@ const ModalCreate = ({
) )
case 'text': case 'text':
return ( return (
<TextInput key={index} label={header.field} {...register(header.field, { <Field key={index} label={header.field}>
<Input {...register(header.field, {
required: true required: true
})} /> })} />
</Field>
) )
default: default:
return ( return (
<TextInput key={index} label={header.field} {...register(header.field, { <Field key={index} label={header.field}>
<Input {...register(header.field, {
required: true required: true
})} /> })} />
</Field>
) )
} }
})} })}
<Button mt='xl' type='submit'> <Button style={{ marginTop: '2rem' }} appearance="primary" type='submit'>
Добавить Добавить
</Button> </Button>
</Flex> </form>
</Modal> </Modal>
) )
} }

View File

@ -1,6 +1,6 @@
import { Card, Flex, SimpleGrid, Text } from "@mantine/core"; import { CompoundButton, Text } from "@fluentui/react-components";
import { IconBuildingFactory2, IconFiles, IconMap, IconReport, IconServer, IconShield, IconUsers } from "@tabler/icons-react"; import { BuildingColor, CloudColor, DocumentColor, FormColor, MapFilled, PeopleListColor, ShieldColor } from "@fluentui/react-icons";
import { ReactNode } from "react"; //import { IconBuildingFactory2, IconFiles, IconMap, IconReport, IconServer, IconShield, IconUsers } from "@tabler/icons-react";
import { useNavigate } from "react-router-dom"; import { useNavigate } from "react-router-dom";
export default function Main() { export default function Main() {
@ -8,47 +8,56 @@ export default function Main() {
interface CustomCardProps { interface CustomCardProps {
link: string; link: string;
icon: ReactNode; icon: any;
label: string; label: string;
secondaryLabel?: string;
} }
const CustomCard = ({ const CustomCard = ({
link, link,
icon, icon,
label label,
secondaryLabel
}: CustomCardProps) => { }: CustomCardProps) => {
return ( return (
<Card <CompoundButton
onClick={() => navigate(link)} onClick={() => navigate(link)}
withBorder
style={{ cursor: 'pointer', userSelect: 'none' }} style={{ cursor: 'pointer', userSelect: 'none' }}
icon={icon}
secondaryContent={secondaryLabel}
> >
<Flex mih='50'>
{icon}
</Flex>
<Text fw={500} size="lg" mt="md"> <Text weight={'bold'} size={400}>
{label} {label}
</Text> </Text>
</Card> </CompoundButton>
) )
} }
return ( return (
<Flex w={'100%'} h={'100%'} direction='column' gap='sm' p='sm'> <div style={{
<Text size="xl" fw={700}> display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '1rem',
padding: '1rem'
}}>
<Text size={600} weight='bold'>
Главная Главная
</Text> </Text>
<SimpleGrid cols={{ xs: 1, md: 3 }}> <div style={{
<CustomCard link="/user" icon={<IconUsers size='50' color="#6495ED" />} label="Пользователи" /> display: 'flex',
<CustomCard link="/role" icon={<IconShield size='50' color="#6495ED" />} label="Роли" /> gap: '1rem',
<CustomCard link="/documents" icon={<IconFiles size='50' color="#6495ED" />} label="Документы" /> flexWrap: 'wrap'
<CustomCard link="/reports" icon={<IconReport size='50' color="#6495ED" />} label="Отчеты" /> }}>
<CustomCard link="/servers" icon={<IconServer size='50' color="#6495ED" />} label="Серверы" /> <CustomCard link="/user" icon={<PeopleListColor color="#6495ED" />} label="Пользователи" secondaryLabel="Управление пользователями"/>
<CustomCard link="/boilers" icon={<IconBuildingFactory2 size='50' color="#6495ED" />} label="Котельные" /> <CustomCard link="/role" icon={<ShieldColor color="#6495ED" />} label="Роли" />
<CustomCard link="/map-test" icon={<IconMap size='50' color="#6495ED" />} label="ИКС" /> <CustomCard link="/documents" icon={<DocumentColor color="#6495ED" />} label="Документы" secondaryLabel="Обзор файлов/документов"/>
</SimpleGrid> <CustomCard link="/reports" icon={<FormColor color="#6495ED" />} label="Отчеты" secondaryLabel="Просмотр и создание отчетных документов"/>
<CustomCard link="/servers" icon={<CloudColor color="#6495ED" />} label="Серверы" secondaryLabel="Мониторинг серверов"/>
</Flex> <CustomCard link="/boilers" icon={<BuildingColor color="#6495ED" />} label="Котельные" />
<CustomCard link="/map-test" icon={<MapFilled color="#6495ED" />} label="ИКС" secondaryLabel="Инженерно-картографическая система"/>
</div>
</div>
) )
} }

View File

@ -1,14 +1,19 @@
import { Container, Stack, Tabs } from '@mantine/core' import { useEffect } from "react";
import MapComponent from '../components/map/MapComponent' import { v4 as uuidv4 } from "uuid";
import { useEffect } from 'react' import { Tab, TabList } from "@fluentui/react-tabs";
import { initializeObjectsState } from '../store/objects' import MapComponent from "../components/map/MapComponent";
import { deleteMapTab, setCurrentTab, useAppStore } from '../store/app'
import { initializeMapState, useMapStore } from '../store/map' import {
import { v4 as uuidv4 } from 'uuid' useAppStore,
setCurrentTab,
deleteMapTab,
} from "../store/app";
import { initializeMapState, useMapStore } from "../store/map";
import { initializeObjectsState } from "../store/objects";
function MapTest() { function MapTest() {
const { mapTab, currentTab } = useAppStore() const { mapTab, currentTab } = useAppStore();
const { id } = useMapStore() const { id } = useMapStore();
const tabs = [ const tabs = [
{ {
@ -23,12 +28,13 @@ function MapTest() {
// region: 11, // region: 11,
// district: 146, // district: 146,
// }, // },
] ];
useEffect(() => { useEffect(() => {
tabs.map(tab => useAppStore.setState((state) => { tabs.forEach((tab) => {
initializeObjectsState(tab.id, tab.region, tab.district, null, tab.year) useAppStore.setState((state) => {
initializeMapState(tab.id) initializeObjectsState(tab.id, tab.region, tab.district, null, tab.year);
initializeMapState(tab.id);
return { return {
mapTab: { mapTab: {
@ -36,47 +42,46 @@ function MapTest() {
[tab.id]: { [tab.id]: {
year: tab.year, year: tab.year,
region: tab.region, region: tab.region,
district: tab.district district: tab.district,
} },
} },
} };
})) });
});
setCurrentTab(tabs[0].id) setCurrentTab(tabs[0].id);
return () => { return () => {
tabs.map(tab => deleteMapTab(tab.id)) tabs.forEach((tab) => deleteMapTab(tab.id));
} };
}, []) }, []);
return ( return (
<Container fluid w='100%' pos='relative' p={0}> <div style={{ height: "100%", width: "100%", position: "relative" }}>
<Tabs h='100%' variant='default' value={currentTab} onChange={setCurrentTab} keepMounted={true}> <div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<Stack gap={0} h='100%'> <TabList
<Tabs.List> selectedValue={currentTab}
onTabSelect={(_, data) => setCurrentTab(data.value as string)}
>
{Object.entries(mapTab).map(([key]) => ( {Object.entries(mapTab).map(([key]) => (
<Tabs.Tab value={key} key={key}> <Tab value={key} key={key}>
{id[key]?.mapLabel} {id[key]?.mapLabel ?? `Tab ${key}`}
</Tabs.Tab> </Tab>
))} ))}
</Tabs.List> </TabList>
{Object.entries(mapTab).map(([key]) => ( <div style={{ flexGrow: 1, position: "relative" }}>
<Tabs.Panel value={key} key={key} h='100%' pos='relative'> {Object.entries(mapTab).map(([key]) =>
<MapComponent currentTab === key ? (
key={key} <div key={key} style={{ height: "100%", position: "relative" }}>
id={key} <MapComponent key={key} id={key} active={true} />
active={currentTab === key} </div>
/> ) : null
)}
</Tabs.Panel> </div>
))} </div>
</Stack> </div>
);
</Tabs>
</Container>
)
} }
export default MapTest export default MapTest;

View File

@ -1,5 +1,5 @@
import { Card } from '@fluentui/react-components';
import { useEffect, useState } from 'react' import { useEffect, useState } from 'react'
import { Card, Flex } from '@mantine/core';
function CardComponent({ function CardComponent({
url, url,
@ -7,10 +7,10 @@ function CardComponent({
}: { url: string, is_alive: boolean }) { }: { url: string, is_alive: boolean }) {
return ( return (
<Card> <Card>
<Flex p='sm' direction='column'> <div>
<p>{url}</p> <p>{url}</p>
<p>{JSON.stringify(is_alive)}</p> <p>{JSON.stringify(is_alive)}</p>
</Flex> </div>
</Card> </Card>
) )
} }
@ -38,11 +38,15 @@ export default function MonitorPage() {
return ( return (
<div> <div>
<Flex direction='column' gap='sm'> <div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
{servers.length > 0 && servers.map((server: { name: string, status: boolean }) => ( {servers.length > 0 && servers.map((server: { name: string, status: boolean }) => (
<CardComponent url={server.name} is_alive={server.status} /> <CardComponent url={server.name} is_alive={server.status} />
))} ))}
</Flex> </div>
</div> </div>
) )
} }

View File

@ -1,15 +1,29 @@
import { Flex, Text } from "@mantine/core"; import { Text } from "@fluentui/react-components";
import { makeStyles } from "@fluentui/react-components";
import { IconError404 } from "@tabler/icons-react"; import { IconError404 } from "@tabler/icons-react";
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}
})
export default function NotFound() { export default function NotFound() {
const classes = useStyles()
return ( return (
<Flex w={'100%'} h={'100%'} p='sm' gap='sm' align='center' justify='center'> <div style={{
<Flex direction='column' gap='sm' align='center'> width: '100%',
height: '100%',
}}>
<div className={classes.root}>
<IconError404 size={100} /> <IconError404 size={100} />
<Text size="xl" fw={500} ta='center'> <Text size={500} weight='medium' align='center'>
Запрашиваемая страница не найдена. Запрашиваемая страница не найдена.
</Text> </Text>
</Flex> </div>
</Flex> </div>
) )
} }

View File

@ -1,10 +1,10 @@
import { ActionIcon, Button, Flex, Group, Stack, Text, TextInput } from "@mantine/core"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import createReport, { listCommands } from 'docx-templates' import createReport, { listCommands } from 'docx-templates'
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone' import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone'
import { IconFileTypeDocx, IconPlus, IconUpload, IconX } from "@tabler/icons-react" import { IconFileTypeDocx, IconPlus, IconUpload, IconX } from "@tabler/icons-react"
import { CommandSummary } from "docx-templates/lib/types" import { CommandSummary } from "docx-templates/lib/types"
import { Control, Controller, FieldValues, SubmitHandler, useFieldArray, useForm, UseFormRegister } from "react-hook-form" import { Control, Controller, FieldValues, SubmitHandler, useFieldArray, useForm, UseFormRegister } from "react-hook-form"
import { Button, Field, Input, Text } from "@fluentui/react-components"
const xslTemplate = `<?xml version="1.0" encoding="utf-8"?> const xslTemplate = `<?xml version="1.0" encoding="utf-8"?>
<xsl:stylesheet version="1.0" xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:xsl="http://www.w3.org/1999/XSL/Transform"> <xsl:stylesheet version="1.0" xmlns="urn:schemas-microsoft-com:office:spreadsheet" xmlns:ss="urn:schemas-microsoft-com:office:spreadsheet" xmlns:x="urn:schemas-microsoft-com:office:excel" xmlns:xsl="http://www.w3.org/1999/XSL/Transform">
@ -1024,11 +1024,11 @@ const FormLoop = ({
}) })
return ( return (
<Stack align="center"> <div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<Stack w='100%' key={command.code}> <div style={{ display: 'flex', flexDirection: 'column', width: '100%' }} key={command.code}>
{ {
fields.map((field, index) => ( fields.map((field, index) => (
<Flex w='100%' justify='space-between' align='flex-end' key={field.id}> <div style={{ display: 'flex', width: '100%', justifyContent: 'space-between', alignItems: 'flex-end' }} key={field.id}>
{command.children && {command.children &&
command.children.map(c => command.children.map(c =>
renderCommand( renderCommand(
@ -1040,27 +1040,25 @@ const FormLoop = ({
`${command.code}.${index}.${c.code}` `${command.code}.${index}.${c.code}`
) )
)} )}
<Button variant='subtle' onClick={() => { <Button appearance='subtle' onClick={() => {
remove(index) remove(index)
}}> }}>
<IconX /> <IconX />
</Button> </Button>
</Flex> </div>
)) ))
} }
</Stack> </div>
<ActionIcon onClick={() => { <Button icon={<IconPlus />} onClick={() => {
if (command.children) { if (command.children) {
append(command.children.map(c => c.code).reduce((acc, key) => { append(command.children.map(c => c.code).reduce((acc, key) => {
acc[key] = ''; acc[key] = '';
return acc; return acc;
}, {} as Record<string, string>)) }, {} as Record<string, string>))
} }
}}> }} />
<IconPlus /> </div>
</ActionIcon>
</Stack>
) )
} }
@ -1074,18 +1072,17 @@ const renderCommand = (
) => { ) => {
if (command.type === 'INS') { if (command.type === 'INS') {
return ( return (
<TextInput <Field label={label}
label={label} key={key}>
key={key} <Input {...register(name)} />
{...register(name)} </Field>
/>
) )
} }
if (command.type === 'IMAGE') { if (command.type === 'IMAGE') {
return ( return (
<Stack gap={0}> <div style={{ display: 'flex', flexDirection: 'column' }}>
<Text size='sm' fw={500}>{command.code}</Text> <Text size={200} weight="semibold">{command.code}</Text>
<Controller <Controller
key={key} key={key}
name={name} name={name}
@ -1108,7 +1105,7 @@ const renderCommand = (
}} }}
maxFiles={1} maxFiles={1}
> >
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}> <div style={{ display: 'flex', justifyContent: 'center', gap: '2rem', minHeight: '220px', pointerEvents: 'none' }}>
<Dropzone.Accept> <Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} /> <IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept> </Dropzone.Accept>
@ -1120,15 +1117,15 @@ const renderCommand = (
</Dropzone.Idle> </Dropzone.Idle>
<div> <div>
<Text size="xl" inline> <Text size={300}>
Перетащите файлы сюда или нажмите, чтобы выбрать их Перетащите файлы сюда или нажмите, чтобы выбрать их
</Text> </Text>
</div> </div>
</Group> </div>
</Dropzone> </Dropzone>
)} )}
/> />
</Stack> </div>
) )
} }
} }
@ -1240,21 +1237,21 @@ const TemplateForm = ({
if (commandList) { if (commandList) {
return ( return (
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Stack> <div style={{ display: 'flex', flexDirection: 'column' }}>
{commandList.map(command => { {commandList.map(command => {
if (command.type === 'FOR') { if (command.type === 'FOR') {
return ( return (
<Stack gap={0} key={command.code}> <div style={{ display: 'flex', flexDirection: 'column' }} key={command.code}>
<Text size='sm' fw={500}>{command.code}</Text> <Text size={200} weight='semibold'>{command.code}</Text>
<FormLoop control={control} register={register} command={command} /> <FormLoop control={control} register={register} command={command} />
</Stack> </div>
) )
} else { } else {
return renderCommand(control, register, command, command.code, command.code, command.code) return renderCommand(control, register, command, command.code, command.code, command.code)
} }
})} })}
<Button ml='auto' w='fit-content' type='submit'>Сохранить</Button> <Button style={{ marginLeft: 'auto', width: 'fit-content' }} type='submit'>Сохранить</Button>
</Stack> </div>
</form> </form>
) )
} }
@ -1262,13 +1259,13 @@ const TemplateForm = ({
const PrintReport = () => { const PrintReport = () => {
return ( return (
<Stack p='sm' gap='sm' w='100%'> <div style={{ display: 'flex', flexDirection: 'column', padding: '1rem', gap: '1rem', width: '100%' }}>
<TemplateForm templateUrl="/template_table.docx" /> <TemplateForm templateUrl="/template_table.docx" />
<Flex gap='sm'> <div style={{ display: 'flex', gap: '1rem' }}>
<Button onClick={handleGenerateExcel}>Сохранить в Excel</Button> <Button onClick={handleGenerateExcel}>Сохранить в Excel</Button>
</Flex> </div>
</Stack> </div>
) )
} }

View File

@ -3,8 +3,8 @@ import { useCities, useReport, useReportExport } from "../hooks/swrHooks"
import { useDebounce } from "@uidotdev/usehooks" import { useDebounce } from "@uidotdev/usehooks"
import { ICity } from "../interfaces/fuel" import { ICity } from "../interfaces/fuel"
import { mutate } from "swr" import { mutate } from "swr"
import { ActionIcon, Autocomplete, Badge, Button, CloseButton, Flex, ScrollAreaAutosize, Table } from "@mantine/core"
import { IconRefresh } from "@tabler/icons-react" import { IconRefresh } from "@tabler/icons-react"
import { Badge, Button, Combobox, createTableColumn, DataGrid, DataGridBody, DataGridCell, DataGridHeader, DataGridHeaderCell, DataGridRow, Option, TableCellLayout, TableColumnDefinition } from "@fluentui/react-components"
export default function Reports() { export default function Reports() {
const [download, setDownload] = useState(false) const [download, setDownload] = useState(false)
@ -40,99 +40,125 @@ export default function Reports() {
} }
return ( return (
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'> <div style={{
<Flex component="form" gap={'sm'}> width: '100%',
height: '100%',
padding: '1rem'
}}>
<form style={{
display: 'flex',
gap: '0.5rem'
}}>
{/* <SearchableSelect /> */} {/* <SearchableSelect /> */}
<Autocomplete <Combobox clearable placeholder="Населенный пункт" onOptionSelect={(_, data) => {
placeholder="Населенный пункт" setSelectedOption(Number(data.optionValue))
flex={'1'} setSearch(data.optionText ?? "")
data={cities ? cities.map((item: ICity) => ({ label: item.name, value: item.id.toString() })) : []} }} value={search} onChange={(e) => setSearch(e.currentTarget.value)}>
onSelect={(e) => console.log(e.currentTarget.value)} {cities && Array.isArray(cities) && cities.map((item: ICity) => (
onChange={(value) => setSearch(value)} <Option key={item.id} value={item.id.toString()}>
onOptionSubmit={(value) => setSelectedOption(Number(value))} {item.name}
rightSection={ </Option>
search !== '' && ( ))}
<CloseButton </Combobox>
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSearch('')
setSelectedOption(null)
}}
aria-label="Clear value"
/>
)
}
value={search}
/>
<ActionIcon size='auto' variant='transparent' onClick={() => refreshReport()}> <Button icon={<IconRefresh />} appearance="subtle" onClick={() => refreshReport()}>
<IconRefresh />
</ActionIcon> </Button>
<Button disabled={!selectedOption} onClick={() => exportReport()}> <Button disabled={!selectedOption} onClick={() => exportReport()}>
Экспорт Экспорт
</Button> </Button>
</Flex> </form>
<div style={{
display: 'flex',
width: '100%',
overflow: 'auto'
}}>
{report && {report &&
<Table highlightOnHover> <ReportTable report={report} />
<Table.Thead> }
<Table.Tr> </div>
{[
{ field: 'id', headerName: '№', width: 70 }, </div>
...Object.keys(report).map(key => ({ )
field: key, }
headerName: key.charAt(0).toUpperCase() + key.slice(1),
width: 150
})) interface ReportType {
].map(column => ( [key: string]: Record<string, unknown>;
<Table.Th key={column.headerName}>{column.headerName}</Table.Th> }
))}
</Table.Tr> function ReportTable({ report }: { report: ReportType }) {
</Table.Thead> // Build column definitions
<Table.Tbody> const columns: TableColumnDefinition<any>[] = [
{[...new Set(Object.keys(report).flatMap(key => Object.keys(report[key])))].map(id => { createTableColumn({
columnId: "id",
renderHeaderCell: () => {
return "№"
},
renderCell: (item) => <TableCellLayout>{item.id}</TableCellLayout>,
}),
...Object.keys(report).map((key) =>
createTableColumn({
columnId: key,
renderHeaderCell: () => {
return key.charAt(0).toUpperCase() + key.slice(1)
},
renderCell: (item: any) => {
if (key === "Активность") {
return (
<TableCellLayout>
{item["Активность"] ? (
<Badge color="success">Активен</Badge>
) : (
<Badge color="danger">Отключен</Badge>
)}
</TableCellLayout>
);
}
return <TableCellLayout>{item[key] as string}</TableCellLayout>;
},
})
),
];
// Build rows from report (same logic you used)
const items = [...new Set(Object.keys(report).flatMap((key) => Object.keys(report[key])))]
.map((id) => {
const row: Record<string, unknown> = { id: Number(id) }; const row: Record<string, unknown> = { id: Number(id) };
Object.keys(report).forEach(key => { Object.keys(report).forEach((key) => {
row[key] = report[key][id]; row[key] = report[key][id];
}); });
return (<Table.Tr key={row.id as number}> return row;
{[ });
{ field: 'id', headerName: '№', width: 70 },
...Object.keys(report).map(key => ({
field: key,
headerName: key.charAt(0).toUpperCase() + key.slice(1),
width: 150
}))
].map(column => {
if (column.field === 'Активность') {
return (
row['Активность'] ? (
<Table.Td key={`${row.id}-${column.headerName}`}>
<Badge fullWidth variant="light">
Активен
</Badge>
</Table.Td>
) : (
<Table.Td key={`${row.id}-${column.headerName}`}>
<Badge color="gray" fullWidth variant="light">
Отключен
</Badge>
</Table.Td>
)
)
}
return ( return (
<Table.Td key={`${row.id}-${column.headerName}`}>{row[column.field] as string}</Table.Td> <DataGrid
) items={items}
})} columns={columns}
</Table.Tr>) sortable
})} focusMode='row_unstable'
</Table.Tbody> resizableColumns
</Table> resizableColumnsOptions={{ autoFitColumns: false }}
} size='extra-small'
</ScrollAreaAutosize>
) >
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody>
{({ item, rowId }) => (
<DataGridRow key={rowId}>
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
</DataGridRow>
)}
</DataGridBody>
</DataGrid>
);
} }

View File

@ -1,8 +1,8 @@
import { useRoles } from '../hooks/swrHooks' import { useRoles } from '../hooks/swrHooks'
import { CreateField } from '../interfaces/create' import { CreateField } from '../interfaces/create'
import RoleService from '../services/RoleService' import RoleService from '../services/RoleService'
import { Loader, Stack } from '@mantine/core'
import CustomTable from '../components/CustomTable' import CustomTable from '../components/CustomTable'
import { Spinner } from '@fluentui/react-components'
export default function Roles() { export default function Roles() {
const { roles, isError, isLoading } = useRoles() const { roles, isError, isLoading } = useRoles()
@ -13,30 +13,34 @@ export default function Roles() {
] ]
if (isError) return <div>Произошла ошибка при получении данных.</div> if (isError) return <div>Произошла ошибка при получении данных.</div>
if (isLoading) return <Loader /> if (isLoading) return <Spinner />
return ( return (
<Stack w={'100%'} h={'100%'} p='sm'> <div style={{
width: '100%',
height: '100%',
padding: '1rem'
}} >
<CustomTable <CustomTable
createFields={createFields} createFields={createFields}
submitHandler={RoleService.createRole} submitHandler={RoleService.createRole}
data={roles} columns={[ data={roles} columns={[
{ {
accessorKey: 'id', name: 'id',
header: 'id', header: 'id',
cell: (info) => info.getValue(), type: 'number'
}, },
{ {
accessorKey: 'name', name: 'name',
header: 'Название', header: 'Название',
cell: (info) => info.getValue(), type: 'string'
}, },
{ {
accessorKey: 'description', name: 'description',
header: 'Описание', header: 'Описание',
cell: (info) => info.getValue(), type: 'string'
}, },
]} /> ]} />
</Stack> </div>
) )
} }

View File

@ -3,46 +3,52 @@ import ServersView from "../components/ServersView"
import ServerIpsView from "../components/ServerIpsView" import ServerIpsView from "../components/ServerIpsView"
import ServerHardware from "../components/ServerHardware" import ServerHardware from "../components/ServerHardware"
import ServerStorage from "../components/ServerStorages" import ServerStorage from "../components/ServerStorages"
import { Flex, ScrollAreaAutosize, Tabs } from "@mantine/core" import { Tab, TabList } from "@fluentui/react-components"
export default function Servers() { export default function Servers() {
const [currentTab, setCurrentTab] = useState<string | null>('0') const tabs = [{
id: 'servers',
name: 'Серверы',
content: <ServersView />
},
{
id: 'ips',
name: 'IP-адреса',
content: <ServerIpsView />
},
{
id: 'hardware',
name: 'Hardware',
content: <ServerHardware />
},
{
id: 'storage',
name: 'Хранилище',
content: <ServerStorage />
}
]
const [selectedTab, setSelectedTab] = useState<string | unknown>(tabs[0].id)
return ( return (
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'> <div style={{
<Flex gap='sm' direction='column'> display: 'flex',
<Tabs value={currentTab} onChange={setCurrentTab}> flexDirection: 'column',
<Tabs.List> width: '100%',
<Tabs.Tab value="0">Серверы</Tabs.Tab> height: '100%',
<Tabs.Tab value="1">IP-адреса</Tabs.Tab> gap: '1rem',
<Tabs.Tab value="3">Hardware</Tabs.Tab> padding: '1rem'
<Tabs.Tab value="4">Storages</Tabs.Tab> }}>
</Tabs.List> <TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
{tabs.map(tab => (
<Tab value={tab.id}>{tab.name}</Tab>
))}
</TabList>
<Tabs.Panel value="0" pt='sm'> <div>
<ServersView /> {tabs.find(tab => tab.id === selectedTab)?.content}
</Tabs.Panel> </div>
</div>
<Tabs.Panel value="1" pt='sm'>
<ServerIpsView />
</Tabs.Panel>
<Tabs.Panel value="2" pt='sm'>
<ServerHardware />
</Tabs.Panel>
<Tabs.Panel value="3" pt='sm'>
<ServerStorage />
</Tabs.Panel>
</Tabs>
</Flex>
{/* <BarChart
xAxis={[{ scaleType: 'band', data: ['group A', 'group B', 'group C'] }]}
series={[{ data: [4, 3, 5] }, { data: [1, 6, 3] }, { data: [2, 5, 6] }]}
width={500}
height={300}
/> */}
</ScrollAreaAutosize>
) )
} }

View File

@ -5,7 +5,6 @@ import { CreateField } from "../interfaces/create"
import { IUser } from "../interfaces/user" import { IUser } from "../interfaces/user"
import FormFields from "../components/FormFields" import FormFields from "../components/FormFields"
import AuthService from "../services/AuthService" import AuthService from "../services/AuthService"
import { Flex, ScrollAreaAutosize } from "@mantine/core"
export default function Settings() { export default function Settings() {
const { token } = useAuthStore() const { token } = useAuthStore()
@ -39,13 +38,20 @@ export default function Settings() {
] ]
return ( return (
<ScrollAreaAutosize <div
w={'100%'} style={{
h={'100%'} width: '100%',
p='sm' height: '100%',
padding: '1rem'
}}
> >
{currentUser && {currentUser &&
<Flex direction='column' gap='sm' w='100%'> <div style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '1rem'
}}>
<FormFields <FormFields
fields={profileFields} fields={profileFields}
defaultValues={currentUser} defaultValues={currentUser}
@ -61,8 +67,8 @@ export default function Settings() {
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })} submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
title="Смена пароля" title="Смена пароля"
/> />
</Flex> </div>
} }
</ScrollAreaAutosize> </div>
) )
} }

View File

@ -3,8 +3,9 @@ import { IRole } from "../interfaces/role"
import { useEffect, useState } from "react" import { useEffect, useState } from "react"
import { CreateField } from "../interfaces/create" import { CreateField } from "../interfaces/create"
import UserService from "../services/UserService" import UserService from "../services/UserService"
import { Flex, Loader, Stack } from "@mantine/core"
import CustomTable from "../components/CustomTable" import CustomTable from "../components/CustomTable"
import { Spinner } from "@fluentui/react-components"
import { IUser } from "../interfaces/user"
export default function Users() { export default function Users() {
const { users, isError, isLoading } = useUsers() const { users, isError, isLoading } = useUsers()
@ -13,12 +14,20 @@ export default function Users() {
const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>() const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>()
const [data, setData] = useState<IUser[]>([])
useEffect(() => { useEffect(() => {
if (Array.isArray(roles)) { if (Array.isArray(roles)) {
setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() }))) setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() })))
} }
}, [roles]) }, [roles])
useEffect(() => {
if (users) {
setData(users)
}
}, [users])
const createFields: CreateField[] = [ const createFields: CreateField[] = [
{ key: 'email', headerName: 'E-mail', type: 'string', required: true, defaultValue: '' }, { key: 'email', headerName: 'E-mail', type: 'string', required: true, defaultValue: '' },
{ key: 'login', headerName: 'Логин', type: 'string', required: true, defaultValue: '' }, { key: 'login', headerName: 'Логин', type: 'string', required: true, defaultValue: '' },
@ -36,57 +45,71 @@ export default function Users() {
if (isLoading) { if (isLoading) {
return ( return (
<Flex direction='column' align='flex-start' gap='sm' p='sm'> <div>
<Loader /> <Spinner />
</Flex> </div>
) )
} }
return ( return (
<Stack w={'100%'} h={'100%'} p='xs'> <div style={{
{Array.isArray(roleOptions) && display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
padding: '1rem'
}}>
{Array.isArray(roleOptions) && Array.isArray(data) &&
<CustomTable <CustomTable
createFields={createFields} createFields={createFields}
submitHandler={UserService.createUser} submitHandler={UserService.createUser}
data={users} data={data}
onEditCell={(rowId, columnId, value) => {
console.log(rowId, columnId, value)
setData((prev) =>
prev.map((row) =>
row.id === rowId ? { ...row, [columnId]: value } : row
)
)
}}
columns={[ columns={[
{ {
accessorKey: 'email', name: 'email',
header: 'E-mail', header: 'E-mail',
cell: (info) => info.getValue(), type: "string"
}, },
{ {
accessorKey: 'login', name: 'login',
header: 'Логин', header: 'Логин',
cell: (info) => info.getValue(), type: "string"
}, },
{ {
accessorKey: 'phone', name: 'phone',
header: 'Телефон', header: 'Телефон',
cell: (info) => info.getValue(), type: "string"
}, },
{ {
accessorKey: 'name', name: 'name',
header: 'Имя', header: 'Имя',
cell: (info) => info.getValue(), type: "string"
}, },
{ {
accessorKey: 'surname', name: 'surname',
header: 'Фамилия', header: 'Фамилия',
cell: (info) => info.getValue(), type: "string"
}, },
{ {
accessorKey: 'is_active', name: 'is_active',
header: 'Активен', header: 'Активен',
cell: (info) => info.getValue(), type: "boolean"
}, },
{ {
accessorKey: 'role_id', name: 'role_id',
header: 'Роль', header: 'Роль',
cell: (info) => info.getValue(), type: "dictionary" //TODO: dictionary getter by id
} }
]} /> ]} />
} }
</Stack> </div>
) )
} }

View File

@ -1,8 +1,8 @@
import { useState } from 'react' import { useState } from 'react'
import { SubmitHandler, useForm } from 'react-hook-form'; import { SubmitHandler, useForm } from 'react-hook-form';
import AuthService from '../../services/AuthService'; import AuthService from '../../services/AuthService';
import { Button, Flex, Loader, Paper, Text, TextInput, Transition } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react'; import { IconCheck } from '@tabler/icons-react';
import { Button, Input, Spinner, Text } from '@fluentui/react-components';
interface PasswordResetProps { interface PasswordResetProps {
email: string; email: string;
@ -11,7 +11,7 @@ interface PasswordResetProps {
function PasswordReset() { function PasswordReset() {
const [success, setSuccess] = useState(false) const [success, setSuccess] = useState(false)
const { register, handleSubmit, watch, setError, formState: { errors, isSubmitting } } = useForm<PasswordResetProps>({ const { register, handleSubmit, watch, setError, formState: { isSubmitting } } = useForm<PasswordResetProps>({
defaultValues: { defaultValues: {
email: '' email: ''
} }
@ -31,65 +31,81 @@ function PasswordReset() {
} }
return ( return (
<Paper flex={1} maw='500' withBorder radius='md' p='xl'> <div style={{
<Flex direction='column' gap='sm'> display: 'flex',
<Text size="xl" fw={500}> margin: 'auto',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
width: '100%',
height: 'min-content',
borderRadius: '1rem',
border: '1px solid #00000030',
padding: '2rem'
}}>
<Text size={600} weight='medium'>
Восстановление пароля Восстановление пароля
</Text> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
{!success && {!success &&
<Transition mounted={!success} transition='fade'> <div style={{
{(styles) => display: 'flex',
<Flex style={styles} direction='column' gap={'md'}> flexDirection: 'column',
gap: '1rem'
}}>
<Text> <Text>
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации: Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
</Text> </Text>
<TextInput <Input
label='E-mail' placeholder='E-mail'
required required
{...register('email', { required: 'Введите E-mail' })} {...register('email', { required: 'Введите E-mail' })}
error={errors.email?.message} //error={errors.email?.message}
/> />
<Flex gap='sm'> <div style={{
<Button flex={1} type="submit" disabled={isSubmitting || watch('email').length == 0} variant='filled'> display: 'flex',
{isSubmitting ? <Loader size={16} /> : 'Восстановить пароль'} width: '100%',
justifyContent: 'space-between'
}}>
<Button type="submit" disabled={isSubmitting || watch('email').length == 0} appearance='primary'>
{isSubmitting ? <Spinner /> : 'Восстановить пароль'}
</Button> </Button>
<Button flex={1} component='a' href="/auth/signin" type="button" variant='light'> <Button as='a' href="/auth/signin" type="button" appearance='subtle'>
Назад Назад
</Button> </Button>
</Flex> </div>
</Flex> </div>
}
</Transition>
} }
{success && {success &&
<Transition mounted={!success} transition='scale'> <div style={{
{(styles) => display: 'flex',
<Flex style={styles} direction='column' gap='sm'> flexDirection: 'column',
<Flex align='center' gap='sm'> gap: '1rem'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem'
}}>
<IconCheck /> <IconCheck />
<Text> <Text>
На указанный адрес было отправлено письмо с новыми данными для авторизации. На указанный адрес было отправлено письмо с новыми данными для авторизации.
</Text> </Text>
</Flex> </div>
<Flex gap='sm'> <div style={{ display: 'flex' }}>
<Button component='a' href="/auth/signin" type="button"> <Button as='a' href="/auth/signin" type="button">
Войти Войти
</Button> </Button>
</Flex> </div>
</Flex> </div>
}
</Transition>
} }
</form> </form>
</Flex> </div>
</Paper>
) )
} }

View File

@ -5,7 +5,8 @@ import { login, setUserData } from '../../store/auth';
import { useNavigate } from 'react-router-dom'; import { useNavigate } from 'react-router-dom';
import AuthService from '../../services/AuthService'; import AuthService from '../../services/AuthService';
import UserService from '../../services/UserService'; import UserService from '../../services/UserService';
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core'; import { Button, Field, Input, Link, Spinner, Text } from '@fluentui/react-components';
import { pages } from '../../constants/app';
const SignIn = () => { const SignIn = () => {
const { register, handleSubmit, setError, formState: { errors, isSubmitting, isValid } } = useForm<LoginFormData>({ const { register, handleSubmit, setError, formState: { errors, isSubmitting, isValid } } = useForm<LoginFormData>({
@ -46,53 +47,63 @@ const SignIn = () => {
message: (err as { detail: string })?.detail message: (err as { detail: string })?.detail
}) })
} }
} }
}; };
return ( return (
<Paper flex={1} maw='500' withBorder radius='md' p='xl'> <div style={{
<Flex direction='column' gap='sm'> display: 'flex',
<Text size="xl" fw={500}> margin: 'auto',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
width: '100%',
height: 'min-content',
borderRadius: '1rem',
border: '1px solid #00000030',
padding: '2rem'
}}>
<Text align='center' size={500} weight='bold'>
Вход Вход
</Text> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Flex direction='column' gap='sm'> <div style={{
<TextInput display: 'flex',
label='Логин' flexDirection: 'column',
gap: '1rem'
}}>
<Field label={'Логин'} validationState={errors.username?.message ? 'error' : 'none'}>
<Input
required required
{...register('username', { required: 'Введите логин' })} {...register('username', { required: 'Введите логин' })}
error={errors.username?.message}
/> />
</Field>
<TextInput <Field label={'Пароль'} validationState={errors.password?.message ? 'error' : 'none'}>
label='Пароль' <Input
type='password'
required required
type='password'
{...register('password', { required: 'Введите пароль' })} {...register('password', { required: 'Введите пароль' })}
error={errors.password?.message}
/> />
</Field>
<Flex justify='flex-end' gap='sm'> <Link href='/auth/password-reset'>
<Button component='a' href='/auth/password-reset' variant='transparent'>
Восстановить пароль Восстановить пароль
</Button> </Link>
</Flex>
<Flex gap='sm'> <Button disabled={!isValid} type="submit" appearance='primary' icon={isSubmitting ? <Spinner size='extra-tiny' /> : undefined}>
<Button disabled={!isValid} type="submit" flex={1} variant='filled'> Вход
{isSubmitting ? <Loader size={16} /> : 'Вход'}
</Button> </Button>
{/* <Button component='a' flex={1} href='/auth/signup' type="button" variant='light'> {pages.find(page => page.path === '/auth/signup')?.enabled &&
<Button as='a' href='/auth/signup' type="button" appearance='subtle'>
Регистрация Регистрация
</Button> */} </Button>}
</Flex> </div>
</Flex>
</form> </form>
</Flex> </div>
</Paper>
); );
}; };

View File

@ -1,7 +1,7 @@
import { useForm, SubmitHandler } from 'react-hook-form'; import { useForm, SubmitHandler } from 'react-hook-form';
import UserService from '../../services/UserService'; import UserService from '../../services/UserService';
import { IUser } from '../../interfaces/user'; import { IUser } from '../../interfaces/user';
import { Button, Flex, Loader, Paper, Text, TextInput } from '@mantine/core'; import { Button, Field, Input, Spinner, Text } from '@fluentui/react-components';
const SignUp = () => { const SignUp = () => {
const { register, handleSubmit, formState: { errors, isValid, isSubmitting } } = useForm<IUser>({ const { register, handleSubmit, formState: { errors, isValid, isSubmitting } } = useForm<IUser>({
@ -26,66 +26,76 @@ const SignUp = () => {
}; };
return ( return (
<Paper flex={1} maw='500' withBorder radius='md' p='xl'> <div style={{
<Flex direction='column' gap='sm'> display: 'flex',
<Text size="xl" fw={500}> margin: 'auto',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
width: '100%',
height: 'min-content',
borderRadius: '1rem',
border: '1px solid #00000030',
padding: '2rem'
}}>
<Text align='center' size={500} weight='bold'>
Регистрация Регистрация
</Text> </Text>
<form onSubmit={handleSubmit(onSubmit)}> <form onSubmit={handleSubmit(onSubmit)}>
<Flex direction='column' gap='sm'> <div style={{
<TextInput display: 'flex',
label='Email' flexDirection: 'column',
gap: '1rem'
}}>
<Field label={'Email'} validationState={errors.email?.message ? 'error' : 'none'}>
<Input
required required
{...register('email', { required: 'Email обязателен' })} {...register('email', { required: 'Email обязателен' })}
error={errors.email?.message}
/> />
</Field>
<TextInput <Field label={'Логин'} validationState={errors.login?.message ? 'error' : 'none'}>
label='Логин' <Input
required required
{...register('login', { required: 'Логин обязателен' })} {...register('login', { required: 'Логин обязателен' })}
error={errors.login?.message}
/> />
</Field>
<TextInput <Field label={'Телефон'} validationState={errors.phone?.message ? 'error' : 'none'}>
label='Телефон' <Input
required required
{...register('phone')} {...register('phone', { required: 'Телефон обязателен' })}
error={errors.phone?.message}
/> />
</Field>
<TextInput <Field label={'Имя'} validationState={errors.name?.message ? 'error' : 'none'}>
label='Имя' <Input
required required
{...register('name')} {...register('name', { required: 'Имя обязательно' })}
error={errors.name?.message}
/> />
</Field>
<TextInput <Field label={'Фамилия'} validationState={errors.surname?.message ? 'error' : 'none'}>
label='Фамилия' <Input
required required
{...register('surname')} {...register('surname', { required: 'Фамилия обязательна' })}
error={errors.surname?.message}
/> />
</Field>
<TextInput <Field label={'Пароль'} validationState={errors.password?.message ? 'error' : 'none'}>
label='Пароль' <Input
type="password"
required required
{...register('password', { required: 'Пароль обязателен' })} {...register('password', { required: 'Пароль обязателен' })}
error={errors.password?.message}
/> />
</Field>
<Flex gap='sm'> <Button disabled={!isValid} type="submit" appearance='primary'>
<Button disabled={!isValid} type="submit" flex={1} variant='filled'> {isSubmitting ? <Spinner /> : 'Зарегистрироваться'}
{isSubmitting ? <Loader size={16} /> : 'Зарегистрироваться'}
</Button> </Button>
</Flex> </div>
</Flex>
</form> </form>
</Flex> </div>
</Paper>
); );
}; };

View File

@ -2,7 +2,7 @@ import { create } from 'zustand';
interface ObjectsState { interface ObjectsState {
id: Record<string, { id: Record<string, {
selectedRegion: number | null; selectedRegion: number | undefined;
selectedDistrict: number | null; selectedDistrict: number | null;
selectedCity: number | null; selectedCity: number | null;
selectedYear: number | null; selectedYear: number | null;
@ -16,7 +16,7 @@ export const useObjectsStore = create<ObjectsState>(() => ({
export const initializeObjectsState = ( export const initializeObjectsState = (
id: string, id: string,
selectedRegion: number | null, selectedRegion: number | undefined,
selectedDistrict: number | null, selectedDistrict: number | null,
selectedCity: number | null, selectedCity: number | null,
selectedYear: number | null, selectedYear: number | null,
@ -46,7 +46,7 @@ export const setSelectedCity = (id: string, city: number | null) => useObjectsSt
}) })
export const getSelectedRegion = (id: string) => useObjectsStore.getState().id[id].selectedRegion export const getSelectedRegion = (id: string) => useObjectsStore.getState().id[id].selectedRegion
export const setSelectedRegion = (id: string, region: number | null) => useObjectsStore.setState((state) => { export const setSelectedRegion = (id: string, region: number | undefined) => useObjectsStore.setState((state) => {
return { return {
id: { id: {
...state.id, ...state.id,

File diff suppressed because it is too large Load Diff

View File

@ -22,6 +22,17 @@ services:
# - ${REDIS_PORT}:${REDIS_PORT} # - ${REDIS_PORT}:${REDIS_PORT}
# restart: unless-stopped # restart: unless-stopped
backend:
container_name: backend
build:
context: ./server
dockerfile: Dockerfile
environment:
- EMS_PORT=${EMS_PORT}
ports:
- ${EMS_PORT}:${EMS_PORT}
restart: always
ems: ems:
container_name: ems container_name: ems
build: build:

17
server/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:lts-alpine AS base
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
EXPOSE $EMS_PORT
CMD ["node", "dist/main"]

View File

@ -18,6 +18,6 @@ async function bootstrap() {
const documentFactory = () => SwaggerModule.createDocument(app, config) const documentFactory = () => SwaggerModule.createDocument(app, config)
SwaggerModule.setup('docs', app, documentFactory) SwaggerModule.setup('docs', app, documentFactory)
await app.listen(process.env.PORT ?? 3000); await app.listen(process.env.EMS_PORT ?? 3000);
} }
bootstrap(); bootstrap();