Compare commits

27 Commits

Author SHA1 Message Date
80517cd7cc remove unused imports 2025-10-07 12:22:23 +09:00
8b8b242d3e MapComponent interaction fixes; 2025-10-07 12:21:05 +09:00
4a6d314472 map interaction fixes; MapPrint cleanup; MapToolbar - add zoom, enable ruler for all modes; 2025-10-07 12:20:28 +09:00
4bc39eb8eb styling; Map page is now at /; enable password reset page 2025-10-07 12:17:53 +09:00
bfb79c96de MapPrint two columns view 2025-10-01 16:39:19 +09:00
66172a69ba MapStatusbar styling 2025-10-01 12:21:48 +09:00
5a7a70aa6c MapMode styling; MapPrint check for mode 2025-10-01 12:09:11 +09:00
bdab63f1bb use Dialog for MapPrint 2025-09-25 17:12:10 +09:00
2ffd94bd5b smaller tab's text in TabsPane; use Drawer in MapComponent for object view 2025-09-25 16:42:19 +09:00
5dd75ead39 render Map tab as 'a'; remove logs; 2025-09-25 16:37:17 +09:00
26132fc1ee ObjectTree: keyed elements, full width; 2025-09-25 16:36:06 +09:00
fc045abe24 put overlays over the map 2025-09-25 11:09:10 +09:00
e2c251f7af remove unused block 2025-09-25 10:40:19 +09:00
06cc2f21a5 remove dnd-kit; add draggable tabs 2025-09-25 10:38:27 +09:00
2b0b08ae4e Region/district select; proper Map tabs 2025-09-24 17:54:38 +09:00
9758ab65b6 smaller Tabs; fix defaultValues for MapPrint; reduce Fill style for regionsLayer; add gis interfaces; 2025-09-24 17:51:33 +09:00
7dd7878e49 server: boundsrequestdto 2025-09-24 17:49:33 +09:00
eefb514098 remove unused logs 2025-09-24 11:13:22 +09:00
bf3638f1d5 server: add POST bounds get method by list of ids 2025-09-24 11:12:59 +09:00
83b94126fc remove unused 2025-09-24 10:11:44 +09:00
037c0b7cf1 Removed mantine libraries; Removed mandatory authentication 2025-09-22 09:38:21 +09:00
c8caec7351 downgrade sharp to 0.33.5 2025-09-18 17:31:44 +09:00
c646cbac15 strict installation 2025-09-18 17:17:04 +09:00
97fc2f40db return buffer 2025-09-18 17:11:53 +09:00
cb371dcf6f sharp 2025-09-18 16:53:49 +09:00
c2560b073b use API_PORT instead of EMS_PORT for NestJS backend 2025-09-18 16:37:35 +09:00
34529cea68 NestJS backend rewrite; migrate client to FluentUI V9 2025-09-18 15:48:08 +09:00
73 changed files with 6622 additions and 4807 deletions

BIN
.DS_Store vendored Normal file

Binary file not shown.

3723
client/package-lock.json generated

File diff suppressed because it is too large Load Diff

View File

@ -12,32 +12,17 @@
},
"dependencies": {
"-": "^0.0.1",
"@dnd-kit/core": "^6.3.1",
"@fluentui/react-components": "^9.69.0",
"@fluentui/react-datepicker-compat": "^0.6.14",
"@fluentui/react-icons": "^2.0.309",
"@fontsource/inter": "^5.0.19",
"@fontsource/open-sans": "^5.0.28",
"@hello-pangea/dnd": "^17.0.0",
"@js-preview/docx": "^1.6.2",
"@js-preview/excel": "^1.7.8",
"@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/dates": "^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/spotlight": "^7.13.0",
"@mantine/tiptap": "^7.13.0",
"@tabler/icons-react": "^3.17.0",
"@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",
"@uidotdev/usehooks": "^2.4.1",
"ag-grid-react": "^33.3.2",
@ -46,9 +31,7 @@
"buffer": "^6.0.3",
"dayjs": "^1.11.13",
"docx-templates": "^4.13.0",
"embla-carousel-react": "^8.3.0",
"file-type": "^19.0.0",
"html2canvas": "^1.4.1",
"jspdf": "^2.5.2",
"ol": "^10.0.0",
"ol-ext": "^4.0.23",
@ -57,7 +40,6 @@
"react-dom": "^18.2.0",
"react-hook-form": "^7.52.0",
"react-router-dom": "^6.23.1",
"recharts": "^2.12.7",
"swr": "^2.2.5",
"uuid": "^11.0.3",
"zustand": "^4.5.2"

View File

@ -1,17 +1,30 @@
import { BrowserRouter as Router, Route, Routes, Navigate } from "react-router-dom"
import { BrowserRouter as Router, Route, Routes } from "react-router-dom"
//import { Navigate } from "react-router-dom"
import NotFound from "./pages/NotFound"
import MainLayout from "./layouts/MainLayout"
import { initAuth, useAuthStore } from "./store/auth"
import { useEffect, useState } from "react"
import DashboardLayout from "./layouts/DashboardLayout"
import { Box, Loader } from "@mantine/core"
import { pages } from "./constants/app"
import { FluentProvider, Spinner, webDarkTheme, webLightTheme } from "@fluentui/react-components"
import { setColorScheme, useAppStore } from "./store/app"
function App() {
const auth = useAuthStore()
const { colorScheme } = useAppStore()
const [isLoading, setIsLoading] = useState(true)
useEffect(() => {
const localColorScheme = localStorage.getItem('colorScheme');
if (localColorScheme === 'light') {
setColorScheme('light')
} else if (localColorScheme === 'dark') {
setColorScheme('dark')
} else if (localColorScheme === 'auto') {
setColorScheme('auto')
}
initAuth()
}, [])
@ -24,28 +37,34 @@ function App() {
if (isLoading) {
return (
<Loader />
<Spinner />
)
} else {
return (
<Box w='100%' h='100vh'>
<Router>
<Routes>
<Route element={<MainLayout />}>
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`ml-${index}`} path={page.path} element={page.component} />
))}
</Route>
<FluentProvider theme={colorScheme === 'light' ? webLightTheme : webDarkTheme}>
<div style={{
width: '100%',
height: '100vh'
}}>
<Router>
<Routes>
<Route element={<MainLayout />}>
{pages.filter((page) => !page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`ml-${index}`} path={page.path} element={page.component} />
))}
</Route>
<Route element={auth.isAuthenticated ? <DashboardLayout></DashboardLayout> : <Navigate to={"/auth/signin"} />}>
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`dl-${index}`} path={page.path} element={page.component} />
))}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</Box>
{/* <Route element={auth.isAuthenticated || pages.find(page => page.path === '/auth/signin')?.enabled === false ? <DashboardLayout></DashboardLayout> : <Navigate to={"/auth/signin"} />}> */}
<Route element={<DashboardLayout></DashboardLayout>}>
{pages.filter((page) => page.dashboard).filter((page) => page.enabled).map((page, index) => (
<Route key={`dl-${index}`} path={page.path} element={page.component} />
))}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</Router>
</div>
</FluentProvider>
)
}
}

View File

@ -1,23 +0,0 @@
import { Divider, Flex, Text } from '@mantine/core';
import { PropsWithChildren } from 'react'
interface CardInfoProps extends PropsWithChildren {
label: string;
}
export default function CardInfo({
children,
label
}: CardInfoProps) {
return (
<Flex direction='column' gap='sm' p='sm'>
<Text fw={600}>
{label}
</Text>
<Divider />
{children}
</Flex>
)
}

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,22 +0,0 @@
import { Flex, Text } from '@mantine/core';
interface CardInfoLabelProps {
label: string;
value: string | number;
}
export default function CardInfoLabel({
label,
value
}: CardInfoLabelProps) {
return (
<Flex justify='space-between' align='center'>
<Text>
{label}
</Text>
<Text fw={600}>
{value}
</Text>
</Flex>
)
}

View File

@ -1,205 +1,198 @@
import { Badge, Button, Flex, Input, Modal, ScrollAreaAutosize, Select, Stack, Table, TextInput } from '@mantine/core';
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 { useState } from 'react';
import { IconPlus } from '@tabler/icons-react';
import { CreateField } from '../interfaces/create';
import { AxiosResponse } from 'axios';
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> = {
data: T[];
columns: ColumnDef<T>[];
data: (T & { id: number })[];
columns: IColumn[];
createFields?: CreateField[];
submitHandler?: (data: T) => Promise<AxiosResponse>
onEditCell?: (rowId: number, columnId: string, value: any) => any
searchable?: boolean
}
const CustomTable = <T extends object>({
data: initialData,
columns,
createFields,
submitHandler
submitHandler,
searchable = false
}: CustomTableProps<T>) => {
const [data, setData] = useState<T[]>(initialData);
const [data, setData] = useState<(T & { id: number })[]>(initialData);
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 = (
rowIndex: number,
columnId: keyof T,
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())
const handleEditCell = (rowId: number, columnId: string, value: any) => {
setData((prev) =>
prev.map((row) =>
row.id === rowId ? { ...row, [columnId]: value } : row
)
);
}, [data, searchText])
)
}
const table = useReactTable({
data: filteredData,
columns: tableColumns,
getCoreRowModel: getCoreRowModel(),
columnResizeMode: "onChange",
});
const columnDefinitions: TableColumnDefinition<any>[] = columns.map(column => (
createTableColumn<any>({
columnId: column.name,
renderHeaderCell: () => column.header,
renderCell: (item) => {
const isEditing = editingCell.rowId === item.id && editingCell.columnId === column.name;
const [opened, { open, close }] = useDisclosure(false);
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 (
<Stack h='100%'>
{createFields && submitHandler &&
<Modal opened={opened} onClose={close} title="Добавление объекта" centered>
<FormFields
fields={createFields}
submitHandler={submitHandler}
/>
</Modal>
}
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
<div style={{
display: 'flex',
gap: '1rem'
}}>
{searchable &&
<Input
placeholder="Поиск"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
/>}
<Flex w='100%' gap='sm'>
<TextInput
placeholder="Поиск"
value={searchText}
onChange={(e) => setSearchText(e.target.value)}
w='100%'
/>
{createFields && submitHandler &&
<Button
leftSection={<IconPlus />}
onClick={open}
style={{ flexShrink: 0 }}
>
Добавить
</Button>
<Dialog>
<DialogTrigger>
<Button
appearance='primary'
icon={<IconPlus />}
style={{ flexShrink: 0 }}
>
Добавить
</Button>
</DialogTrigger>
<DialogSurface>
<DialogTitle>Добавление объекта</DialogTitle>
<FormFields
fields={createFields}
submitHandler={submitHandler}
/>
</DialogSurface>
</Dialog>
}
</Flex>
</div>
<ScrollAreaAutosize offsetScrollbars style={{ borderRadius: '4px' }}>
<Table stickyHeader striped withColumnBorders highlightOnHover className={styles.table}>
<Table.Thead className={styles.thead}>
{table.getHeaderGroups().map(headerGroup => (
<Table.Tr key={headerGroup.id} className={styles.tr}>
{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
/>
) : (
<CellDisplay cell={cell} />
)}
</Table.Td>
);
})}
</Table.Tr>
))}
</Table.Tbody>
</Table>
</ScrollAreaAutosize>
</Stack>
<DataGrid
items={data}
columns={columnDefinitions}
resizableColumns
focusMode="cell"
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody>
{({ item, rowId }) => (
<DataGridRow key={rowId}>
{({ renderCell }) => <DataGridCell>{renderCell(item)}</DataGridCell>}
</DataGridRow>
)}
</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;

View File

@ -4,23 +4,14 @@ import React, { useEffect, useState } from 'react'
import DocumentService from '../services/DocumentService'
import { mutate } from 'swr'
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, IconFile, IconFileFilled, IconFilePlus, IconFileUpload, IconFolderFilled, IconX } from '@tabler/icons-react'
import { IconCancel, IconDownload, IconFileUpload, 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 {
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 link = document.createElement('a')
link.href = window.URL.createObjectURL(file)
@ -30,6 +21,27 @@ const handleSave = async (file: Blob, filename: string) => {
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) {
const [shouldFetch, setShouldFetch] = useState(false)
@ -45,22 +57,16 @@ function ItemDocument({ doc }: DocumentProps) {
}, [shouldFetch, file, doc.name])
return (
<Flex>
<ActionIcon
onClick={(e) => {
e.stopPropagation()
if (!isLoading) {
setShouldFetch(true)
}
}}
variant='subtle'>
{isLoading ?
<Loader size='sm' />
:
<IconDownload />
}
</ActionIcon>
</Flex>
<Button icon={isLoading ?
<Spinner size='tiny' />
:
<IconDownload />
} appearance='subtle' onClick={(e) => {
e.stopPropagation()
if (!isLoading) {
setShouldFetch(true)
}
}} />
)
}
@ -134,14 +140,41 @@ 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) {
return (
<Loader />
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '1rem',
}}>
<Spinner />
</div>
)
}
return (
<ScrollAreaAutosize w={'100%'} h={'100%'} p={'sm'}>
<div style={{
width: '100%',
height: '100%',
padding: '1rem',
}}>
{fileViewerModal &&
<FileViewer
open={fileViewerModal}
@ -152,51 +185,74 @@ export default function FolderViewer() {
/>
}
<Stack>
<Breadcrumbs>
<Anchor
onClick={() => {
<div style={{
display: 'flex',
flexDirection: 'column',
height: '100%',
width: '100%',
gap: '1rem'
}}>
<Breadcrumb>
<BreadcrumbItem>
<BreadcrumbButton onClick={() => {
setCurrentFolder(null)
setBreadcrumbs([])
}}
>
Главная
</Anchor>
}}>Главная</BreadcrumbButton>
</BreadcrumbItem>
{breadcrumbs.map((breadcrumb, index) => (
<Anchor
key={breadcrumb.id}
onClick={() => handleBreadcrumbClick(index)}
>
{breadcrumb.name}
</Anchor>
<>
<BreadcrumbDivider />
<BreadcrumbItem key={breadcrumb.id}>
<BreadcrumbButton icon={<FolderRegular />} onClick={() => {
handleBreadcrumbClick(index)
}}>{breadcrumb.name}</BreadcrumbButton>
</BreadcrumbItem>
</>
))}
</Breadcrumbs>
</Breadcrumb>
{currentFolder &&
<Flex direction='column' gap='sm'>
<Flex direction='column' gap='sm' p='sm' style={{
<div 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',
borderRadius: '8px',
}}>
<Flex gap='sm'>
<FileButton multiple onChange={handleFileInput}>
{(props) => <Button variant='filled' leftSection={isUploading ? <Loader /> : <IconFilePlus />} {...props}>Добавить</Button>}
</FileButton>
<div style={{ display: 'flex', gap: '1rem' }}>
<input
type="file"
multiple
ref={fileInputRef}
style={{ display: "none" }}
onChange={handleFileChange}
/>
<Button appearance="primary" icon={<DocumentAdd20Regular />} onClick={handleClick}>
Добавить
</Button>
{filesToUpload.length > 0 &&
<>
<Button
variant='filled'
leftSection={isUploading ? <RingProgress sections={[{ value: uploadProgress, color: 'blue' }]} /> : <IconFileUpload />}
appearance='primary'
icon={<IconFileUpload />}
onClick={uploadFiles}
disabled={isUploading}
>
Загрузить все
</Button>
<Button
variant='outline'
leftSection={<IconCancel />}
appearance='outline'
icon={<IconCancel />}
onClick={() => {
setFilesToUpload([])
}}
@ -205,88 +261,114 @@ export default function FolderViewer() {
</Button>
</>
}
</Flex>
</div>
{isUploading &&
<Field validationMessage={"Загрузка файлов..."} validationState='none'>
<ProgressBar value={uploadProgress} />
</Field>
}
<Divider />
{filesToUpload.length > 0 &&
<Flex direction='column'>
<div style={{
display: 'flex',
flexDirection: 'column'
}}>
{filesToUpload.map((file, index) => (
<Flex key={index} p='8px'>
<Flex gap='sm' direction='row' align='center'>
<IconFile />
<Text>{file.name}</Text>
</Flex>
<div style={{
display: 'flex',
}} key={index}>
<Button appearance='transparent' icon={<DocumentColor />}>
{file.name}
</Button>
<ActionIcon onClick={() => {
<Button style={{ marginLeft: 'auto' }} appearance='subtle' icon={<IconX />} onClick={() => {
setFilesToUpload(prev => {
return prev.filter((_, i) => i != index)
})
}} ml='auto' variant='subtle'>
<IconX />
</ActionIcon>
</Flex>
}} />
</div>
))}
</Flex>
</div>
}
</Flex>
</Flex>
</div>
</div>
}
<Table
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
bg={dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit'}
highlightOnHover>
<Table.Thead>
<Table.Tr>
<Table.Th>Название</Table.Th>
<Table.Th p={0}>Дата создания</Table.Th>
<Table.Th p={0}></Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{currentFolder ? (
documents?.map((doc: IDocument, index: number) => (
<Table.Tr key={doc.id} onClick={() => handleDocumentClick(index)} style={{ cursor: 'pointer' }}>
<Table.Td p={0}>
<Flex style={FileItemStyle}>
<IconFileFilled />
{doc.name}
</Flex>
</Table.Td>
<Table.Td p={0}>
{new Date(doc.create_date).toLocaleDateString()}
</Table.Td>
<Table.Td p={0}>
<ItemDocument
doc={doc}
/>
</Table.Td>
</Table.Tr>
))
) : (
folders?.map((folder: IDocumentFolder) => (
<Table.Tr key={folder.id} onClick={() => handleFolderClick(folder)} style={{ cursor: 'pointer' }}>
<Table.Td p={0}>
<Flex style={FileItemStyle}>
<IconFolderFilled />
{folder.name}
</Flex>
</Table.Td>
<Table.Td p={0} align='left'>
{new Date(folder.create_date).toLocaleDateString()}
</Table.Td>
<Table.Td p={0}>
</Table.Td>
</Table.Tr>
))
)}
</Table.Tbody>
</Table>
</Stack>
</ScrollAreaAutosize>
<div style={{
width: '100%',
overflow: 'auto'
}}>
<DataGrid
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
style={{ backgroundColor: dragOver ? 'rgba(0, 0, 0, 0.1)' : 'inherit' }}
items={currentFolder
? (documents ?? []).map((doc: IDocument) => ({ kind: "doc", data: doc }))
: (folders ?? []).map((folder: IDocumentFolder) => ({ kind: "folder", data: folder }))}
columns={[
createTableColumn({
columnId: "name",
renderHeaderCell: () => "Название",
renderCell: (item) => (
<TableCellLayout truncate media={item.kind === "doc" ? handleDocFormatIcon(item.data.name) : <FolderRegular />}>
{item.data.name}
</TableCellLayout>
),
}),
createTableColumn({
columnId: "date",
renderHeaderCell: () => "Дата создания",
renderCell: (item) =>
new Date(item.data.create_date).toLocaleDateString(),
}),
createTableColumn({
columnId: "actions",
renderHeaderCell: () => "",
renderCell: (item) => {
if (item.kind === "doc") {
// replace with your <ItemDocument doc={doc} />
return <ItemDocument doc={item.data} />;
}
return null;
},
}),
]}
focusMode="cell"
resizableColumns
getRowId={(item) => item.data.id}
>
<DataGridHeader>
<DataGridRow>
{({ renderHeaderCell }) => (
<DataGridHeaderCell>{renderHeaderCell()}</DataGridHeaderCell>
)}
</DataGridRow>
</DataGridHeader>
<DataGridBody>
{({ 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 { CreateField } from '../interfaces/create'
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 {
title?: string;
@ -51,42 +51,37 @@ function FormFields({
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack gap='sm' w='100%'>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', width: '100%' }}>
{title.length > 0 &&
<Text size="xl" fw={500}>
<Text size={500} weight='semibold'>
{title}
</Text>
}
{fields.map((field: CreateField) => {
return (
<TextInput
key={field.key}
label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}
//placeholder="Your name"
type={field.inputType ? field.inputType : 'text'}
{...register(field.key, {
required: field.required ? `${field.headerName} обязателен` : false,
validate: (val: string | boolean) => {
if (field.watch) {
if (watch(field.watch) != val) {
return field.watchMessage || ''
<Field key={field.key} validationState={errors[field.key]?.message ? 'error' : 'none'} label={field.headerName || field.key.charAt(0).toUpperCase() + field.key.slice(1)}>
<Input type={field.inputType ? field.inputType : 'text'}
{...register(field.key, {
required: field.required ? `${field.headerName} обязателен` : false,
validate: (val: string | boolean) => {
if (field.watch) {
if (watch(field.watch) != val) {
return field.watchMessage || ''
}
}
}
},
})}
radius="md"
required={field.required || false}
error={errors[field.key]?.message}
errorProps={errors[field.key]}
/>
},
})}
required={field.required || false}
/>
</Field>
)
})}
<Button disabled={isSubmitting || Object.keys(dirtyFields).length === 0 || !isValid} type='submit'>
{isSubmitting ? <Loader size={16} /> : submitButtonText}
{isSubmitting ? <Spinner size='extra-small' /> : submitButtonText}
</Button>
</Stack>
</div>
</form>
)
}

View File

@ -1,41 +0,0 @@
import { IServer } from '../interfaces/servers'
import { useServerIps } from '../hooks/swrHooks'
import { Flex, Table } from '@mantine/core'
function ServerData({ id }: IServer) {
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 (
<Flex direction='column' p='sm'>
{serverIps &&
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{serverIpsColumns.map(column => (
<Table.Th key={column.field}>{column.headerName}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
{serverIpsColumns.map(column => (
<Table.Td key={column.field}>{serverIps ? serverIps[column.field] : ''}</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
}
</Flex>
)
}
export default ServerData

View File

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

View File

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

View File

@ -1,42 +1,26 @@
import { useState } from 'react'
import { IRegion } from '../interfaces/fuel'
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() {
const [selectedOption] = useState<IRegion | null>(null)
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 (
<>
{serversLoading ?
<Loader />
<Spinner />
:
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{storageColumns.map(column => (
<Table.Th key={column.field}>{column.headerName}</Table.Th>
))}
</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>
<CustomTable data={storages} columns={[
{ name: 'id', header: 'ID', type: 'number' },
{ name: 'hardware_id', header: 'Hardware ID', type: 'number' },
{ name: 'name', header: 'Название', type: 'string' },
{ name: 'size', header: 'Размер', type: 'string' },
{ name: 'storage_type', header: 'Тип хранилища', type: 'string' }
]} />
}
</>
)

View File

@ -2,7 +2,8 @@ import { useState } from 'react'
import { IRegion } from '../interfaces/fuel'
import { useRegions, useServers } from '../hooks/swrHooks'
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() {
const [search, setSearch] = useState<string | undefined>("")
@ -15,63 +16,46 @@ export default function ServersView() {
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 (
<>
<form>
<Autocomplete
placeholder="Район"
flex={'1'}
data={regions ? regions.map((item: IRegion) => ({ label: item.name, value: item.id.toString() })) : []}
onSelect={(e) => console.log(e.currentTarget.value)}
onChange={(value) => setSearch(value)}
onOptionSubmit={(value) => setSelectedOption(Number(value))}
rightSection={
search !== '' && (
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSearch('')
setSelectedOption(null)
}}
aria-label="Clear value"
/>
)
<Combobox
clearable
placeholder="Район"
style={{ flex: 1 }}
onChange={(e) => setSearch(e.currentTarget.value)}
selectedOptions={selectedOption ? [selectedOption.toString()] : []}
onOptionSelect={(_, data) => {
if (data.optionValue) {
setSelectedOption(Number(data.optionValue));
} else {
setSelectedOption(null)
}
value={search}
/>
</form>
}}
>
{regions?.map((item: IRegion) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
</Option>
))}
</Combobox>
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{serversColumns.map(column => (
<Table.Th key={column.field}>{column.headerName}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
<Table.Tr>
{serversColumns.map(column => (
<Table.Td key={column.field}>{servers ? servers[column.field] : ''}</Table.Td>
))}
</Table.Tr>
</Table.Tbody>
</Table>
{servers &&
<CustomTable
data={servers}
columns={[
{
name: 'name',
header: 'Название',
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 { fetcher } from '../../http/axiosInstance'
import { BASE_URL } from '../../constants'
import { NavLink, Stack, Text } from '@mantine/core';
import { IconChevronDown } from '@tabler/icons-react';
import { setSelectedObjectType } from '../../store/map';
import { setCurrentObjectId, useObjectsStore } from '../../store/objects';
import { Text, Tree, TreeItem, TreeItemLayout } from '@fluentui/react-components';
const ObjectTree = ({
map_id
@ -52,14 +51,14 @@ const ObjectTree = ({
if (selectedDistrict) {
return (
<Stack gap={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} />
</Stack>
<div style={{ width: '100%' }}>
<TypeTree key={'existing'} map_id={map_id} label='Существующие' value={'existing'} count={existingCount} objectList={existingObjectsList} planning={0} />
<TypeTree key={'planning'} map_id={map_id} label='Планируемые' value={'planning'} count={planningCount} objectList={planningObjectsList} planning={1} />
</div>
)
} else {
return (
<Text size='xs'>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
<Text size={500}>Выберите регион и населённый пункт, чтобы увидеть список объектов.</Text>
)
}
@ -84,11 +83,17 @@ const TypeTree = ({
}: TypeTreeProps) => {
return (
<NavLink px='xs' py={0} label={`${label} ${count ? `(${count})` : ''}`}>
{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} />
))}
</NavLink>
<Tree size="small" aria-label="Small Size Tree">
<TreeItem key={`branch-${label}`} itemType="branch">
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
<Tree>
{Array.isArray(objectList) && objectList.map(list => (
<ObjectList map_id={map_id} key={`${label}-${list.id}`} label={list.name} id={list.id} planning={planning} count={list.count} />
))}
</Tree>
</TreeItem>
</Tree>
)
}
@ -119,24 +124,22 @@ const ObjectList = ({
)
const navLinks = useMemo(() => (
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)} />
Array.isArray(data) ? data.map((type, index) => (
<TreeItem key={`${label}-${id}-${index}`} itemType='leaf' onClick={() => setCurrentObjectId(map_id, type.object_id)}>
<TreeItemLayout>{type.caption ? type.caption : 'Без названия'}</TreeItemLayout>
</TreeItem>
)) : null
), [data, map_id]);
return (
<NavLink onClick={() => setSelectedObjectType(map_id, id)} rightSection={<IconChevronDown size={14} />} px='xs' py={0} label={`${label} ${count ? `(${count})` : ''}`}>
{navLinks}
</NavLink>
);
<TreeItem itemType='branch' onClick={() => setSelectedObjectType(map_id, id)}>
<TreeItemLayout>{`${label} ${count ? `(${count})` : ''}`}</TreeItemLayout>
// return (
// <NavLink onClick={() => { setSelectedObjectType(map_id, id) }} rightSection={<IconChevronDown size={14} />} p={0} label={`${label} ${count ? `(${count})` : ''}`}>
// {Array.isArray(data) && data.map((type) => (
// <NavLink key={type.object_id} label={type.caption ? type.caption : 'Без названия'} p={0} onClick={() => setCurrentObjectId(map_id, type.object_id)} />
// ))}
// </NavLink>
// )
<Tree>
{navLinks}
</Tree>
</TreeItem>
)
}
export default ObjectTree

View File

@ -7,16 +7,15 @@ import { IRectCoords, SatelliteMapsProvider } from '../../interfaces/map'
import { Extent, getCenter } from 'ol/extent'
import { highlightStyleRed, highlightStyleYellow } from './MapStyles'
import { customMapSource, googleMapsSatelliteSource, yandexMapsSatelliteSource } from './MapSources'
import { Geometry, SimpleGeometry } from 'ol/geom'
import { Geometry } from 'ol/geom'
import { fromExtent } from 'ol/geom/Polygon'
import { Coordinate } from 'ol/coordinate'
import { addInteractions, handleImageDrop, loadFeatures, processFigure, processLine } from './mapUtils'
import { addInteractions, getFeatureByEntityId, handleImageDrop, loadFeatures, processFigure, processLine, zoomToFeature } from './mapUtils'
import useSWR, { SWRConfiguration } from 'swr'
import { fetcher } from '../../http/axiosInstance'
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 { IconBoxMultiple, IconBoxPadding, IconChevronDown, IconChevronLeft, IconPlus, IconSearch, IconUpload, } from '@tabler/icons-react'
import { ICitySettings, IFigure, ILine } from '../../interfaces/gis'
import { IconBoxMultiple, IconBoxPadding, IconChevronLeft, IconPlus, IconUpload, IconX, } from '@tabler/icons-react'
import { ICitySettings, IDistrict, IFigure, ILine } from '../../interfaces/gis'
import axios from 'axios'
import MapToolbar from './MapToolbar/MapToolbar'
import MapStatusbar from './MapStatusbar/MapStatusbar'
@ -34,11 +33,19 @@ import GisService from '../../services/GisService'
import MapMode from './MapMode'
import { satMapsProviders, schemas } from '../../constants/map'
import MapPrint from './MapPrint/MapPrint'
import { Field, Menu, MenuButton, MenuList, MenuPopover, MenuTrigger, Combobox, Option, Button, Divider, Spinner, Portal, Dropdown, Tooltip, Drawer, DrawerHeader, DrawerBody, Text, Link } from '@fluentui/react-components'
import { IRegion } from '../../interfaces/fuel'
import { useAppStore } from '../../store/app'
import { getDistrictData, getRegionData, setDistrictsData, setRegionsData } from '../../store/regions'
import { ArrowLeft24Regular } from '@fluentui/react-icons'
import View from 'ol/View'
import { Style } from 'ol/style'
const swrOptions: SWRConfiguration = {
revalidateOnFocus: false
}
// City settings mockup
function getCitySettings() {
return {
city_id: 0,
@ -58,8 +65,9 @@ const MapComponent = ({
id: string,
active: boolean,
}) => {
const { colorScheme } = useAppStore()
// Store
const { colorScheme } = useMantineColorScheme()
const { selectedYear, currentObjectId, selectedRegion, selectedDistrict } = useObjectsStore().id[id]
const {
mode, map, currentTool, alignMode, satMapsProvider,
@ -67,8 +75,8 @@ const MapComponent = ({
polygonExtent, rectCoords, draw, snap, translate,
drawingLayerSource,
satLayer, staticMapLayer, figuresLayer, linesLayer,
regionsLayer, districtBoundLayer, baseLayer,
printAreaDraw,
regionsLayer, districtBoundLayer, districtsLayer,
printAreaDraw, statusText, statusTextPosition, regionSelect, districtSelect
} = useMapStore().id[id]
// Tab settings
@ -85,8 +93,8 @@ const MapComponent = ({
// Map
const mapElement = useRef<HTMLDivElement | null>(null)
const tooltipRef = useRef<HTMLDivElement | null>(null)
// Get type roles
useSWR(`/gis/type-roles`, (url) => fetcher(url, BASE_URL.ems).then(res => {
if (Array.isArray(res)) {
setTypeRoles(id, res)
@ -94,42 +102,10 @@ const MapComponent = ({
return res
}), swrOptions)
// Bounds: region
const { data: regionBoundsData } = useSWR(`/gis/bounds/region`, (url) => fetcher(url, BASE_URL.ems), swrOptions)
useEffect(() => {
if (regionsLayer && regionBoundsData) {
if (Array.isArray(regionBoundsData)) {
regionBoundsData.map(bound => {
const geoJson = new GeoJSON({ featureProjection: 'EPSG:3857' })
const geometry = geoJson.readGeometry(bound) as Geometry
const feature = new Feature(geometry)
feature.setProperties(bound.properties)
regionsLayer.getSource()?.addFeature(feature)
})
}
//regionsLayer.current.getSource()?.addFeature()
}
}, [regionBoundsData])
useEffect(() => {
if (selectedDistrict && selectedYear) {
const bounds = new VectorSource({
url: `${BASE_URL.ems}/gis/bounds/city/${selectedDistrict}`,
format: new GeoJSON(),
})
districtBoundLayer.setSource(bounds)
bounds.on('featuresloadend', function () {
// map.current?.setView(new View({
// extent: bounds.getExtent()
// }))
})
}
}, [selectedDistrict, selectedYear])
// Map init
useEffect(() => {
map?.setTarget(mapElement.current as HTMLDivElement)
@ -141,6 +117,91 @@ const MapComponent = ({
loadFeatures(id)
}, [])
// First step: On region bounds loaded
useEffect(() => {
if (regionsLayer && regionBoundsData) {
if (Array.isArray(regionBoundsData)) {
regionBoundsData.map(bound => {
const geoJson = new GeoJSON() //new GeoJSON({ featureProjection: 'EPSG:3857' })
const geometry = geoJson.readGeometry(bound) as Geometry
const feature = new Feature(geometry)
feature.setProperties(bound.properties)
regionsLayer.getSource()?.addFeature(feature)
})
}
}
return () => {
if (regionsLayer) {
regionsLayer.getSource()?.clear()
}
}
}, [regionBoundsData])
useEffect(() => {
if (selectedRegion === null && regionBoundsData) {
if (map) {
const extent = regionsLayer.getSource()?.getExtent()
if (extent) {
map?.setView(new View({
extent: extent,
showFullExtent: true,
}))
map.getView().fit(fromExtent(extent), { padding: [100, 100, 100, 100] })
}
regionsLayer.getSource()?.forEachFeature((feature) => {
if (feature.getProperties()['entity_id'] !== selectedRegion) {
feature.setStyle()
}
})
map.addInteraction(regionSelect)
}
}
}, [regionBoundsData, selectedRegion, map, regionsLayer])
useEffect(() => {
if (selectedRegion && map) {
regionsLayer.getSource()?.forEachFeature((feature) => {
if (feature.getProperties()['entity_id'] !== selectedRegion) {
feature.setStyle(new Style())
}
})
}
}, [selectedRegion, map])
// Last step: once selected scheme
useEffect(() => {
if (selectedDistrict && selectedYear && districtBoundLayer) {
const bounds = new VectorSource({
url: `${BASE_URL.ems}/gis/bounds/city/${selectedDistrict}`,
format: new GeoJSON(),
})
districtBoundLayer.setSource(bounds)
//
bounds.on('featuresloadend', function () {
map?.setView(new View({
extent: bounds.getExtent()
}))
})
}
return () => {
if (districtBoundLayer) {
districtBoundLayer.getSource()?.clear()
}
}
}, [selectedDistrict, selectedYear, districtBoundLayer])
// Edit Mode: add interaction on tool change
useEffect(() => {
if (currentTool) {
if (draw) map?.removeInteraction(draw)
@ -171,19 +232,13 @@ const MapComponent = ({
}
}, [satMapsProvider, satLayer])
// Upload map overlay
const submitOverlay = async (file: File | null, polygonExtent: Extent | undefined, rectCoords: IRectCoords | undefined) => {
await GisService.uploadOverlay(file, polygonExtent, rectCoords).then(res => {
console.log(res)
})
}
const mapControlsStyle: MantineStyleProp = {
borderRadius: '4px',
zIndex: '1',
backgroundColor: colorScheme === 'light' ? '#F0F0F0CC' : '#000000CC',
backdropFilter: 'blur(8px)',
}
// const { data: nodes } = useSWR('/nodes/all', () => fetcher('/nodes/all', BASE_URL.ems), { revalidateOnFocus: false })
// useEffect(() => {
@ -238,16 +293,12 @@ const MapComponent = ({
if (currentObjectId) {
if (figuresLayer) {
// Reset styles and apply highlight to matching features
console.log(currentObjectId)
figuresLayer.getSource()?.getFeatures().forEach((feature: Feature) => {
if (currentObjectId == feature.get('object_id')) {
if (Array.isArray(feature.get('object_id')) ? feature.get('object_id')[0] === currentObjectId : currentObjectId === feature.get('object_id')) {
feature.setStyle(highlightStyleRed)
const geometry = feature.getGeometry()
if (geometry) {
map?.getView().fit(geometry as SimpleGeometry, { duration: 500, maxZoom: 18 })
}
zoomToFeature(id, feature)
} else {
feature.setStyle(undefined) // Reset to default style
}
@ -257,24 +308,27 @@ const MapComponent = ({
if (linesLayer) {
// Reset styles and apply highlight to matching features
linesLayer.getSource()?.getFeatures().forEach((feature: Feature) => {
if (currentObjectId == feature.get('object_id')) {
if (Array.isArray(feature.get('object_id')) ? feature.get('object_id')[0] === currentObjectId : currentObjectId === feature.get('object_id')) {
feature.setStyle(highlightStyleRed)
const geometry = feature.getGeometry()
if (geometry) {
map?.getView().fit(geometry as SimpleGeometry, { duration: 500, maxZoom: 18 })
}
zoomToFeature(id, feature)
} else {
feature.setStyle(undefined) // Reset to default style
}
})
}
}
}, [currentObjectId])
}, [currentObjectId, figuresLayer, linesLayer])
const { data: regionsData } = useSWR(`/general/regions`, (url) => fetcher(url, BASE_URL.ems), swrOptions)
const { data: regionsData } = useSWR(`/general/regions`, (url) => fetcher(url, BASE_URL.ems).then(res => {
setRegionsData(res)
return res
}), swrOptions)
const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.ems), swrOptions)
const { data: districtsData } = useSWR(selectedRegion ? `/general/districts/?region_id=${selectedRegion}` : null, (url) => fetcher(url, BASE_URL.ems).then(res => {
setDistrictsData(res)
return res
}), swrOptions)
const { data: searchData } = useSWR(
throttledSearchObject !== "" && selectedDistrict && selectedYear ? `/general/search/objects?q=${throttledSearchObject}&id_city=${selectedDistrict}&year=${selectedYear}` : null,
@ -319,13 +373,15 @@ const MapComponent = ({
useEffect(() => {
if (selectedDistrict === null) {
setSelectedYear(id, null)
map?.addInteraction(districtSelect)
}
if (selectedRegion === null) {
setSelectedYear(id, null)
setSelectedDistrict(id, null)
}
}, [selectedDistrict, selectedRegion, id])
}, [selectedDistrict, selectedRegion, id, map])
const [leftPaneHidden, setLeftPaneHidden] = useState(false)
@ -379,13 +435,6 @@ const MapComponent = ({
}, [figuresData, linesData, selectedDistrict, selectedYear, districtBoundLayer])
useEffect(() => {
if (!selectedRegion) {
setSelectedRegion(id, null)
setSelectedYear(id, null)
}
}, [selectedRegion, selectedDistrict, id])
useEffect(() => {
if (selectedDistrict && districtData) {
const settings = getCitySettings()
@ -405,12 +454,7 @@ const MapComponent = ({
const center = [settings.offset_x + (wk), settings.offset_y - (hk)]
const extent = [
center[0] - (wk),
center[1] - (hk),
center[0] + (wk),
center[1] + (hk),
]
const extent = [center[0] - (wk), center[1] - (hk), center[0] + (wk), center[1] + (hk)]
// Set up the initial image layer with the extent
const imageSource = new ImageStatic({
@ -425,34 +469,6 @@ const MapComponent = ({
}
}, [selectedDistrict, districtData, staticMapLayer])
// Light/dark theme
useEffect(() => {
if (baseLayer) {
baseLayer.on('prerender', function (e) {
if (colorScheme === 'dark') {
if (e.context) {
const context = e.context as CanvasRenderingContext2D
context.filter = 'grayscale(80%) invert(100%) hue-rotate(180deg) '
context.globalCompositeOperation = 'source-over'
}
} else {
if (e.context) {
const context = e.context as CanvasRenderingContext2D
context.filter = 'none'
}
}
})
baseLayer.on('postrender', function (e) {
if (e.context) {
const context = e.context as CanvasRenderingContext2D
context.filter = 'none'
}
})
}
}, [colorScheme])
useEffect(() => {
if (map) {
if (mode === 'print') {
@ -463,168 +479,390 @@ const MapComponent = ({
}
}, [mode, map, printAreaDraw])
useEffect(() => {
if (districtsData && Array.isArray(districtsData)) {
const list: Number[] = []
districtsData.map(district => {
list.push(district.id as Number)
})
fetch(`${BASE_URL.ems}/gis/bounds/city`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({ list })
}).then(async response => {
const data = await response.json()
if (Array.isArray(data)) {
data.map(bound => {
const geoJson = new GeoJSON() //new GeoJSON({ featureProjection: 'EPSG:3857' })
const geometry = geoJson.readGeometry(bound) as Geometry
const feature = new Feature(geometry)
feature.setProperties(bound.properties)
districtsLayer.getSource()?.addFeature(feature)
})
}
})
}
}, [districtsData])
const mapTooltipRef = useRef<HTMLDivElement>(null)
useEffect(() => {
if (mapTooltipRef.current) {
const leftOffset = 30
const topOffset = -30
mapTooltipRef.current.style.left = (statusTextPosition[0] + leftOffset).toString() + 'px'
mapTooltipRef.current.style.top = (statusTextPosition[1] + topOffset).toString() + 'px'
}
}, [statusTextPosition, mapTooltipRef])
// zoom on region select
useEffect(() => {
if (selectedRegion && !selectedDistrict) {
const feature = getFeatureByEntityId(selectedRegion, regionsLayer)
if (feature) {
regionSelect.getFeatures().push(feature)
map?.setView(new View({
extent: feature?.getGeometry()?.getExtent(),
showFullExtent: true,
}))
}
zoomToFeature(id, feature)
} else if (selectedDistrict) {
// zoom on district select
const feature = getFeatureByEntityId(selectedDistrict, districtsLayer)
if (feature) {
districtSelect.getFeatures().push(feature)
regionsLayer.setOpacity(0)
}
map?.setView(new View({
extent: feature?.getGeometry()?.getExtent(),
showFullExtent: true,
}))
zoomToFeature(id, feature)
} else if (!selectedRegion) {
setSelectedRegion(id, null)
setSelectedYear(id, null)
}
}, [selectedRegion, selectedDistrict, id])
useEffect(() => {
if (selectedYear) {
regionsLayer.setOpacity(0)
districtsLayer.setOpacity(0)
}
}, [selectedYear])
return (
<>
<div style={{ display: 'flex', flexDirection: 'column', position: 'relative', width: '100%', height: '100%' }}>
<MapPrint id={id} mapElement={mapElement} />
{active &&
<Portal target='#header-portal'>
<Flex gap={'sm'} direction={'row'}>
<Autocomplete
form='search_object'
<Portal mountNode={document.querySelector('#header-portal')}>
<div style={{ display: 'flex', gap: '1rem' }}>
<Combobox
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}
/>
<MantineSelect
placeholder="Регион"
flex={'1'}
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)
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setCurrentObjectId(id, data.optionValue);
setSearchObject(
searchData?.find((item: any) => item.id_object.toString() === data.optionValue)?.value ?? ""
);
}
}}
onClear={() => setSelectedYear(id, null)}
value={selectedYear ? selectedYear?.toString() : null}
onChange={(e) => {
setSearchObject(e.currentTarget.value); // free typing like Mantine's onChange
}}
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)}>
<IconBoxPadding style={{ width: rem(20), height: rem(20) }} />
</Button>
<Button icon={<IconBoxPadding />} appearance={alignMode ? 'primary' : 'transparent'} onClick={() => setAlignMode(id, !alignMode)} />
<Menu position="bottom-end" transitionProps={{ transition: 'pop-top-right' }}>
<Menu.Target>
<Button variant='transparent'>
<Group gap={7} wrap='nowrap' style={{ flexShrink: 0 }} title='Слои'>
<IconBoxMultiple style={{ width: rem(20), height: rem(20) }} />
<IconChevronDown style={{ width: rem(12), height: rem(12) }} stroke={1.5} />
</Group>
</Button>
</Menu.Target>
<Menu.Dropdown miw={300}>
<Menu.Label>{'Настройка видимости слоёв'}</Menu.Label>
<Menu persistOnItemClick positioning={{ autoSize: true }}>
<MenuTrigger disableButtonEnhancement>
<MenuButton appearance='subtle' icon={<IconBoxMultiple />}>Слои</MenuButton>
</MenuTrigger>
<Flex p='sm' direction='column' gap='xs'>
<Flex align='center' direction='row' gap='sm'>
<MantineSelect value={satMapsProvider} data={satMapsProviders} onChange={(value) => setSatMapsProvider(id, value as SatelliteMapsProvider)} />
</Flex>
<Flex direction='row'>
<ActionIcon size='lg' variant='transparent' onClick={() => submitOverlay(file, polygonExtent, rectCoords)}>
<IconUpload style={{ width: rem(20), height: rem(20) }} />
</ActionIcon>
<MenuPopover>
<MenuList style={{ padding: '1rem' }}>
<Field>Настройка видимости слоёв</Field>
<ActionIcon size='lg' variant='transparent' title='Добавить подложку'>
<IconPlus style={{ width: rem(20), height: rem(20) }} />
</ActionIcon>
</Flex>
<div style={{ display: 'flex', alignItems: 'center', gap: '1rem' }}>
<Field label="Спутниковые снимки">
<Dropdown
value={satMapsProviders.find(provider => provider.value === satMapsProvider)?.label}
selectedOptions={[satMapsProvider]}
onOptionSelect={(_ev, data) => {
if (data.optionValue) {
setSatMapsProvider(id, data.optionValue as SatelliteMapsProvider);
}
}}
>
{satMapsProviders.map((provider) => (
<Option key={provider.value} text={provider.label} value={provider.value}>
{provider.label}
</Option>
))}
</Dropdown>
</Field>
</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} />
</Flex>
</Menu.Dropdown>
</MenuList>
</MenuPopover>
</Menu>
</Flex>
</Portal>
</div>
</Portal >
}
<Container pos='absolute' w='100%' h='100%' p='0' fluid>
<Flex direction='column' w='100%' h='100%'>
<Flex w='100%' h='94%' p='xs' style={{ flexGrow: 1 }}>
<Stack w='100%' maw='380px'>
<Flex w='100%' h='100%' gap='xs'>
{selectedRegion && selectedDistrict && selectedYear &&
<Flex direction='column' h={'100%'} w={leftPaneHidden ? '0px' : '100%'} style={{ ...mapControlsStyle, transition: 'width .3s ease' }}>
<TabsPane defaultTab='objects' tabs={objectsPane} />
<Divider />
<TabsPane defaultTab='parameters' tabs={paramsPane} />
</Flex>
<div id='mapcomponent' style={{ position: 'relative', width: '100%', height: '100%', display: 'flex' }}>
<div style={{ maxWidth: '30%', maxHeight: '100%' }}>
{!selectedRegion &&
<Drawer style={{ position: 'relative', height: '100%', zIndex: 1 }} open={!selectedRegion} type='inline'>
<DrawerBody>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem', maxHeight: '0' }}>
{regionsData && regionsData.map((region: IRegion) => (
<Link key={region.id} onClick={() => {
setSelectedRegion(id, region.id)
map?.removeInteraction(regionSelect)
}}
onMouseEnter={() => {
const feature = getFeatureByEntityId(region.id, regionsLayer)
if (feature) {
regionSelect.getFeatures().push(feature)
}
}}
onMouseLeave={() => {
regionSelect.getFeatures().clear()
}}
>{region.name}</Link>
))}
</div>
</DrawerBody>
</Drawer>}
<Drawer style={{ position: 'relative', height: '100%', zIndex: 1 }} open={!!selectedRegion && !selectedYear} type='inline'>
<DrawerHeader style={{ flexDirection: 'row' }}>
<Button icon={<ArrowLeft24Regular />} appearance='subtle' onClick={() => {
if (selectedDistrict) {
setSelectedDistrict(id, null)
districtSelect.getFeatures().clear()
regionsLayer.setOpacity(1)
} else {
setSelectedRegion(id, null)
regionSelect.getFeatures().clear()
if (map) {
const extent = regionsLayer.getSource()?.getExtent()
if (extent) {
map.getView().fit(fromExtent(extent), { duration: 100 })
regionsLayer.setOpacity(1)
}
}
}
}} />
{selectedDistrict ?
<Text weight='bold' size={500}>{getDistrictData(selectedDistrict)?.name}</Text>
:
<Text weight='bold' size={500}>{selectedRegion && getRegionData(selectedRegion)?.name}</Text>}
<Button appearance='subtle' style={{ marginLeft: 'auto' }} icon={<IconX />} onClick={() => {
setSelectedYear(id, null)
setSelectedDistrict(id, null)
setSelectedRegion(id, null)
if (map) {
const extent = regionsLayer.getSource()?.getExtent()
if (extent) {
map.getView().fit(fromExtent(extent), { duration: 100 })
regionsLayer.setOpacity(1)
}
}
}} />
</DrawerHeader>
<DrawerBody>
<div key={selectedRegion} style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem', maxHeight: '0' }}>
{selectedDistrict ?
selectedRegion && Object.entries(getRegionData(selectedRegion) as IRegion).map(([key, value]) => (
<div key={key} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{key}</span>
<span>{value}</span>
</div>
))
:
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div>
{selectedRegion && Object.entries(getRegionData(selectedRegion) as IRegion).map(([key, value]) => (
<div key={key} style={{ display: 'flex', justifyContent: 'space-between' }}>
<span>{key}</span>
<span>{value}</span>
</div>
))}
</div>
{districtsData && districtsData.map((district: IDistrict) => (
<Link key={district.id} onClick={() => {
setSelectedDistrict(id, district.id)
map?.removeInteraction(districtSelect)
}}
onMouseEnter={() => {
const feature = getFeatureByEntityId(district.id, districtsLayer)
if (feature) {
districtSelect.getFeatures().push(feature)
}
}}
onMouseLeave={() => {
districtSelect.getFeatures().clear()
}}
>{district.name}</Link>
))}
</div>
}
{selectedDistrict &&
<Field label="Схема" >
<Dropdown
style={{ minWidth: 'auto' }}
value={selectedYear ? selectedYear.toString() : ""}
selectedOptions={[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} text={el}>
{el}
</Option>
))}
</Dropdown>
</Field>
}
</div>
</DrawerBody>
</Drawer>
</div>
<div style={{ position: 'absolute', display: 'flex', flexDirection: 'column', width: '100%', height: '100%' }}>
<div style={{ display: 'flex', height: '94%', padding: '0.5rem', flexGrow: 1 }}>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '380px' }}>
<div style={{ display: 'flex', width: '100%', height: '100%', gap: '0.5rem' }}>
<Drawer style={{ borderRadius: '0.25rem', height: '100%', zIndex: 1 }} type='inline' open={!!selectedRegion && !!selectedDistrict && !!selectedYear && !leftPaneHidden}>
<TabsPane defaultTab='objects' tabs={objectsPane} />
<Divider />
<TabsPane defaultTab='parameters' tabs={paramsPane} />
</Drawer>
{!!selectedRegion && !!selectedDistrict && !!selectedYear &&
<Button p='0' variant='subtle' w='32' style={{ zIndex: '1' }} onClick={() => setLeftPaneHidden(!leftPaneHidden)}>
<IconChevronLeft size={16} style={{ transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}` }} />
</Button>
<Button
icon={<IconChevronLeft size={16}
style={{
transform: `${leftPaneHidden ? 'rotate(180deg)' : ''}`,
}} />}
style={{
zIndex: '1',
display: 'flex',
height: 'min-content'
}}
appearance='subtle'
onClick={() => setLeftPaneHidden(!leftPaneHidden)}
/>
}
</Flex>
</Stack>
</div>
</div>
<Stack w='100%' align='center'>
<Stack style={mapControlsStyle} w='fit-content'>
<MapMode map_id={id} />
</Stack>
</Stack>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', alignItems: 'center' }} >
<div style={{ display: 'flex', flexDirection: 'column', width: 'fit-content' }}>
{selectedDistrict && selectedYear && <MapMode map_id={id} />}
</div>
</div>
<Stack w='100%' maw='340px' align='flex-end' justify='space-between'>
{selectedRegion && selectedDistrict && selectedYear && mode === 'edit' &&
<MapToolbar map_id={id} />
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', maxWidth: '340px', alignItems: 'flex-end', justifyContent: 'space-between', gap: '1rem' }}>
<MapToolbar map_id={id} />
{!!selectedRegion && !!selectedDistrict && !!selectedYear &&
<MapLegend selectedDistrict={selectedDistrict} selectedYear={selectedYear} />
}
</div>
</div>
</div>
<Transition
mounted={!!selectedRegion && !!selectedDistrict && !!selectedYear}
transition="slide-left"
duration={200}
timingFunction="ease"
>
{(styles) => <MapLegend style={styles} selectedDistrict={selectedDistrict} selectedYear={selectedYear} />}
</Transition>
</Stack>
</Flex>
<div id={id} key={id} style={{ position: 'relative', width: '100%', height: '100%', maxHeight: '100%', filter: colorScheme === 'dark' ? 'invert(100%) hue-rotate(180deg)' : 'unset' }} ref={mapElement} onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleImageDrop(e, id)}>
<div>
{statusText && active && !selectedYear &&
<Tooltip hideDelay={0} showDelay={0} content={statusText} relationship='description' visible>
<div style={{ position: 'absolute', zIndex: 9999, userSelect: 'none', pointerEvents: 'none' }} ref={mapTooltipRef}>
{/* {statusText} */}
</div>
</Tooltip>
}
</div>
</div>
</div>
<Flex w='100%'>
<MapStatusbar
map_id={id}
mapControlsStyle={mapControlsStyle}
/>
</Flex>
</Flex>
</Container>
<Container pos='absolute' fluid p={0} w='100%' h='100%' mah='100%' ref={mapElement} onDragOver={(e) => e.preventDefault()} onDrop={(e) => handleImageDrop(e, id)}>
<div ref={tooltipRef}></div>
</Container>
{(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} />
</>
<div style={{ display: 'flex', bottom: '0', width: '100%' }}>
<MapStatusbar
map_id={id}
/>
</div>
</div>
)
}

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

View File

@ -1,20 +1,17 @@
import { Accordion, ActionIcon, Collapse, ColorSwatch, Flex, MantineStyleProp, ScrollAreaAutosize, Stack, Text, useMantineColorScheme } from '@mantine/core'
import useSWR from 'swr'
import { fetcher } from '../../../http/axiosInstance'
import { BASE_URL } from '../../../constants'
import { useDisclosure } from '@mantine/hooks'
import { IconChevronDown } from '@tabler/icons-react'
import { Accordion, AccordionHeader, AccordionItem, AccordionPanel, ColorSwatch, Text } from '@fluentui/react-components'
import { useAppStore } from '../../../store/app'
const MapLegend = ({
selectedDistrict,
selectedYear,
style
}: {
selectedDistrict: number | null,
selectedYear: number | null,
style: MantineStyleProp
}) => {
const { colorScheme } = useMantineColorScheme()
const { colorScheme } = useAppStore()
const { data: existingObjectsList } = useSWR(
selectedYear && selectedDistrict ? `/general/objects/list?year=${selectedYear}&city_id=${selectedDistrict}&planning=0` : null,
@ -32,40 +29,42 @@ const MapLegend = ({
}
)
const [opened, { toggle }] = useDisclosure(false)
return (
<ScrollAreaAutosize maw='300px' w='100%' fz='xs' mt='auto' style={{ ...style, zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
<Stack gap='sm' p='sm'>
<Flex align='center'>
<Text fz='xs'>
<div
style={{ overflow: 'auto', maxWidth: '300px', width: '100%', marginTop: 'auto', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}
>
<Accordion collapsible>
<AccordionItem value='existing'>
<AccordionHeader>
Легенда
</Text>
</AccordionHeader>
<ActionIcon ml='auto' variant='subtle' onClick={toggle} >
<IconChevronDown style={{ transform: opened ? 'rotate(0deg)' : 'rotate(180deg)' }} />
</ActionIcon>
</Flex>
<AccordionPanel>
<Accordion multiple collapsible>
<AccordionItem value='existing'>
<AccordionHeader>
Существующие
</AccordionHeader>
<Collapse in={opened}>
<Accordion defaultValue={['existing', 'planning']} multiple>
<Accordion.Item value='existing' key='existing'>
<Accordion.Control>Существующие</Accordion.Control>
<Accordion.Panel>
{existingObjectsList && <LegendGroup objectsList={existingObjectsList} border='solid' />}
</Accordion.Panel>
</Accordion.Item>
<AccordionPanel>
{existingObjectsList && <LegendGroup objectsList={existingObjectsList} border='solid' />}
</AccordionPanel>
</AccordionItem>
<Accordion.Item value='planning' key='planning'>
<Accordion.Control>Планируемые</Accordion.Control>
<Accordion.Panel>
{planningObjectsList && <LegendGroup objectsList={planningObjectsList} border='dotted' />}
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</Collapse>
</Stack>
</ScrollAreaAutosize>
<AccordionItem value='planning'>
<AccordionHeader>
Планируемые
</AccordionHeader>
<AccordionPanel>
{planningObjectsList && <LegendGroup objectsList={planningObjectsList} border='dotted' />}
</AccordionPanel>
</AccordionItem>
</Accordion>
</AccordionPanel>
</AccordionItem>
</Accordion>
</div>
)
}
@ -89,15 +88,15 @@ const LegendGroup = ({
}
return (
<Stack gap={4}>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{objectsList.map(object => (
<Flex gap='xs' align='center' key={object.id}>
<ColorSwatch style={{ border: borderStyle() }} radius={0} size={16} color={`rgb(${object.r},${object.g},${object.b})`} />
<div style={{ display: 'flex', gap: '0.25rem', alignItems: 'center' }} key={object.id}>
<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>
</Flex>
<Text size={200}>{object.name}</Text>
</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 { Container, Stack, Tabs } from '@mantine/core'
import OlMap from 'ol/Map'
import { v4 as uuidv4 } from 'uuid'
import TileLayer from 'ol/layer/Tile'
@ -12,6 +11,7 @@ import VectorSource from 'ol/source/Vector'
import Feature from 'ol/Feature'
import { LineString } from 'ol/geom'
import { Stroke, Style, Text } from 'ol/style'
import { Tab, TabList } from '@fluentui/react-components'
const center = [14443331.466543002, 8878970.176309839]
@ -106,19 +106,21 @@ const MapLineTest = () => {
}
}, [])
const [selectedTab, setSelectedTab] = useState<string | unknown>('map')
return (
<Container fluid w='100%' pos='relative' p={0}>
<Tabs h='100%' variant='default' value={'map'} keepMounted={true}>
<Stack gap={0} h='100%'>
<Tabs.List>
<Tabs.Tab value={'map'}>Map</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value={'map'} h='100%' pos='relative'>
<Container pos='absolute' fluid p={0} w='100%' h='100%' ref={mapElement}></Container>
</Tabs.Panel>
</Stack>
</Tabs>
</Container>
<div style={{ position: 'relative', width: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<TabList selectedValue='map' onTabSelect={(_, data) => setSelectedTab(data.value)}>
<Tab value='map'>
Map
</Tab>
</TabList>
{selectedTab === 'map' && <div style={{ width: '100%', height: '100%' }} ref={mapElement}></div>}
</div>
</div>
)
}

View File

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

View File

@ -1,14 +1,14 @@
import { ActionIcon, Button, Checkbox, Flex, Modal, Radio, ScrollAreaAutosize, Select, Stack, Text } from '@mantine/core'
import { IconHelp, IconWindowMaximize, IconWindowMinimize } from '@tabler/icons-react'
import React, { useEffect, useRef, useState } from 'react'
import { clearPrintArea, PrintScale, setPreviousView, setPrintScale, setPrintScaleLine, useMapStore } from '../../../store/map'
import { PrintFormat, PrintOrientation, printResolutions, setPrintOrientation, setPrintResolution, usePrintStore } from '../../../store/print'
import { printDimensions, scaleOptions } from '../../../constants/map'
import { useObjectsStore } from '../../../store/objects'
// import { getPointResolution } from 'ol/proj'
import jsPDF from 'jspdf'
import { getCenter } from 'ol/extent'
import ScaleLine from 'ol/control/ScaleLine'
import { Button, Checkbox, Dialog, DialogActions, DialogBody, DialogContent, DialogSurface, DialogTitle, Dropdown, Field, Option, Radio, RadioGroup } from '@fluentui/react-components'
import { Dismiss24Regular } from '@fluentui/react-icons'
const MapPrint = ({
id,
@ -23,6 +23,7 @@ const MapPrint = ({
const {
map,
mode,
previousView, printArea, printSource, printAreaDraw, printScale, printScaleLine,
} = useMapStore().id[id]
@ -44,16 +45,6 @@ const MapPrint = ({
const scaleFactor = width / originalSize[0]
const newResolution = originalResolution / scaleFactor
// console.log(`New resolution: ${newResolution}`)
// const center = map.getView().getCenter()
// let scaleResolution = 1
// if (center) {
// scaleResolution = Number(printScale) / getPointResolution(map.getView().getProjection(), Number(resolution) / 25.4, center)
// // console.log(`Scaled resolution: ${scaleResolution}`)
// }
console.log(width, height)
// Set new high-resolution rendering
map.setSize([width, height])
map.getView().setResolution(newResolution)
@ -116,8 +107,10 @@ const MapPrint = ({
minWidth: 125
}))
const [opened, setOpened] = useState(false)
useEffect(() => {
if (printArea) {
if (printArea && opened) {
// backup view before entering print mode
setPreviousView(id, map?.getView())
@ -130,7 +123,7 @@ const MapPrint = ({
})
map?.removeInteraction(printAreaDraw)
}
}, [printArea, map])
}, [printArea, map, opened])
useEffect(() => {
if (printScaleLine && printArea) {
@ -140,94 +133,101 @@ const MapPrint = ({
}
}, [printScaleLine, printArea])
useEffect(() => {
if (!!printArea) {
setOpened(true)
}
}, [printArea])
useEffect(() => {
if (!opened && mode === 'print') {
clearPrintArea(id)
map?.setTarget(mapElement.current as HTMLDivElement)
map?.addInteraction(printAreaDraw)
}
}, [opened])
return (
<Modal.Root
scrollAreaComponent={ScrollAreaAutosize}
keepMounted size='auto'
opened={!!printArea}
onClose={() => {
clearPrintArea(id)
map?.setTarget(mapElement.current as HTMLDivElement)
map?.addInteraction(printAreaDraw)
}} fullScreen={fullscreen}>
<Modal.Overlay />
<Modal.Content style={{ transition: 'all .3s ease' }}>
<Modal.Header>
<Modal.Title>
Предпросмотр области печати
</Modal.Title>
<Dialog open={opened}>
<DialogSurface style={{ maxWidth: fullscreen ? '100%' : 'fit-content', maxHeight: fullscreen ? '100%' : 'fit-content' }}>
<DialogBody>
<DialogTitle action={
<div style={{ display: 'flex', marginLeft: 'auto', gap: '1.5rem' }}>
<Button appearance='subtle' title='Помощь' style={{ marginLeft: 'auto' }} icon={<IconHelp color='gray' />} />
<Flex ml='auto' gap='md'>
<ActionIcon title='Помощь' ml='auto' variant='transparent'>
<IconHelp color='gray' />
</ActionIcon>
<ActionIcon title={fullscreen ? 'Свернуть' : 'Развернуть'} variant='transparent' onClick={() => setFullscreen(!fullscreen)}>
{fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />}
</ActionIcon>
<Modal.CloseButton title='Закрыть' />
</Flex>
</Modal.Header>
<Modal.Body>
<Stack align='center'>
<Text w='100%'>Область печати можно передвигать.</Text>
<div id='print-portal' style={{
width: printOrientation === 'horizontal' ? '594px' : '420px',
height: printOrientation === 'horizontal' ? '420px' : '594px'
}}>
<Button appearance='subtle' title={fullscreen ? 'Свернуть' : 'Развернуть'} style={{ marginLeft: 'auto' }} icon={fullscreen ? <IconWindowMinimize color='gray' /> : <IconWindowMaximize color='gray' />} onClick={() => setFullscreen(!fullscreen)} />
<Button appearance='subtle' title='Закрыть' icon={<Dismiss24Regular />} onClick={() => setOpened(false)} />
</div>
}>Предпросмотр области печати</DialogTitle>
<Flex w='100%' wrap='wrap' gap='lg' justify='space-between'>
<Radio.Group
label='Ориентация'
value={printOrientation}
onChange={(value) => setPrintOrientation(value as PrintOrientation)}
>
<Stack>
<Radio value='horizontal' label='Горизонтальная' />
<Radio value='vertical' label='Вертикальная' />
</Stack>
</Radio.Group>
<Select
allowDeselect={false}
label="Разрешение"
placeholder="Выберите разрешение"
data={printResolutions}
value={printResolution.toString()}
onChange={(value) => setPrintResolution(Number(value))}
/>
<Select
allowDeselect={false}
label="Масштаб"
placeholder="Выберите масштаб"
data={scaleOptions}
value={printScale}
onChange={(value) => setPrintScale(id, value as PrintScale)}
/>
<Checkbox
checked={printScaleLine}
label="Масштабная линия"
onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)}
/>
</Flex>
<Flex w='100%' gap='sm' align='center'>
<Button ml='auto' onClick={() => {
if (previousView) {
exportToPDF(printFormat, printResolution, printOrientation)
}
<DialogContent style={{ display: 'flex', justifyContent: 'center' }}>
<div style={{ display: 'flex', width: 'fit-content', flexDirection: 'row', alignItems: 'flex-start', height: 'fit-content', overflowY: 'auto' }}>
<div id='print-portal' style={{
width: printOrientation === 'horizontal' ? '594px' : '420px',
height: printOrientation === 'horizontal' ? '420px' : '594px',
flexShrink: '0'
}}>
Печать
</Button>
</Flex>
</Stack>
</Modal.Body>
</Modal.Content>
</Modal.Root>
</div>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%', flexWrap: 'wrap', gap: '1rem', padding: '1rem', justifyContent: 'space-between', alignItems: 'flex-start' }}>
<Field label={'Ориентация'}>
<RadioGroup value={printOrientation} onChange={(_, data) => setPrintOrientation(data.value as PrintOrientation)}>
<Radio value='horizontal' label='Горизонтальная' />
<Radio value='vertical' label='Вертикальная' />
</RadioGroup>
</Field>
<Field label="Разрешение">
<Dropdown
value={printResolution.toString()}
selectedOptions={[printResolution.toString()]}
onOptionSelect={(_, data) => setPrintResolution(Number(data.optionValue))}
>
{printResolutions.map((res) => (
<Option key={res} text={res} value={res}>
{res}
</Option>
))}
</Dropdown>
</Field>
<Field label="Масштаб">
<Dropdown
value={printScale.toString()}
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>
</div>
</div>
</DialogContent>
<DialogActions>
<Checkbox
checked={printScaleLine}
label="Масштабная линия"
onChange={(event) => setPrintScaleLine(id, event.currentTarget.checked)}
/>
<Button style={{ marginLeft: 'auto' }} onClick={() => {
if (previousView) {
exportToPDF(printFormat, printResolution, printOrientation)
}
}}>
Печать
</Button>
</DialogActions>
</DialogBody>
</DialogSurface>
</Dialog>
)
}

View File

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

View File

@ -149,7 +149,7 @@ const regionsLayerStyle = new Style({
width: 1,
}),
fill: new Fill({
color: 'rgba(0, 0, 255, 0.1)',
color: 'rgba(0, 0, 255, 0.01)',
}),
})

View File

@ -1,90 +1,106 @@
import { ActionIcon, Flex, 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, IconTransformPoint } from '@tabler/icons-react'
import { getDraw, setCurrentTool, useMapStore } from '../../../store/map';
import { saveFeatures } from '../mapUtils';
import { Button, Tooltip } from '@fluentui/react-components';
import { useAppStore } from '../../../store/app';
import { useObjectsStore } from '../../../store/objects';
import { AddFilled, RulerRegular, SubtractFilled } from '@fluentui/react-icons';
const MapToolbar = ({
map_id
}: { map_id: string }) => {
const { currentTool } = useMapStore().id[map_id]
const { colorScheme } = useMantineColorScheme();
const { currentTool, mode, map } = useMapStore().id[map_id]
const { selectedRegion, selectedDistrict, selectedYear } = useObjectsStore().id[map_id]
const { colorScheme } = useAppStore();
return (
<Flex>
<ActionIcon.Group orientation='vertical' style={{ zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
<ActionIcon size='lg' variant='transparent' onClick={() => saveFeatures(map_id)}>
<IconExclamationCircle />
</ActionIcon>
<div style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }}>
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
{selectedRegion && selectedDistrict && selectedYear && mode === 'edit' &&
<>
<Button icon={<IconExclamationCircle />} appearance='transparent' onClick={() => saveFeatures(map_id)} />
<ActionIcon size='lg' variant='transparent' onClick={() => getDraw(map_id)?.removeLastPoint()}>
<IconArrowBackUp />
</ActionIcon>
<Tooltip content={"Отмена"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconArrowBackUp />} appearance='transparent' onClick={() => getDraw(map_id)?.removeLastPoint()} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'Edit' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Edit')
}}>
<IconTransformPoint />
</ActionIcon>
<Tooltip content={"Редактировать"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconTransformPoint />} appearance={currentTool === 'Edit' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Edit')
}} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'Point' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Point')
}}>
<IconPoint />
</ActionIcon>
<Tooltip content={"Точка"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconPoint />} appearance={currentTool === 'Point' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Point')
}} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'LineString' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'LineString')
}}>
<IconLine />
</ActionIcon>
<Tooltip content={"Линия"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconLine />} appearance={currentTool === 'LineString' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'LineString')
}} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'Polygon' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Polygon')
}}>
<IconPolygon />
</ActionIcon>
<Tooltip content={"Многоугольник"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconPolygon />} appearance={currentTool === 'Polygon' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Polygon')
}} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'Circle' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Circle')
}}>
<IconCircle />
</ActionIcon>
<Tooltip content={"Окружность"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconCircle />} appearance={currentTool === 'Circle' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Circle')
}} />
</Tooltip>
<ActionIcon
size='lg'
variant={currentTool === 'Mover' ? 'filled' : 'transparent'}
onClick={() => {
setCurrentTool(map_id, 'Mover')
}}
>
<IconArrowsMove />
</ActionIcon>
<Tooltip content={"Перемещение"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<IconArrowsMove />} appearance={currentTool === 'Mover' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Mover')
}} />
</Tooltip>
</>}
</div>
<ActionIcon
size='lg'
variant={currentTool === 'Measure' ? 'filled' : 'transparent'}
onClick={() => {
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
<Tooltip content={"Приблизить"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<AddFilled />} appearance={'transparent'} onClick={() => {
const currentZoom = map?.getView().getZoom()
if (currentZoom) {
// map?.getView().setZoom(currentZoom + 1)
map?.getView().animate({
zoom: currentZoom + 1,
duration: 100
})
}
}} />
</Tooltip>
<Tooltip content={"Отдалить"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<SubtractFilled />} appearance={'transparent'} onClick={() => {
const currentZoom = map?.getView().getZoom()
if (currentZoom) {
// map?.getView().setZoom(currentZoom - 1)
map?.getView().animate({
zoom: currentZoom - 1,
duration: 100
})
}
}} />
</Tooltip>
</div>
<div style={{ display: 'flex', flexDirection: 'column', zIndex: 1, backdropFilter: 'blur(8px)', backgroundColor: colorScheme === 'light' ? '#FFFFFFAA' : '#000000AA', borderRadius: '4px' }}>
<Tooltip content={"Линейка"} relationship='label' hideDelay={0} showDelay={0} withArrow>
<Button icon={<RulerRegular />} appearance={currentTool === 'Measure' ? 'primary' : 'transparent'} onClick={() => {
setCurrentTool(map_id, 'Measure')
}}>
<IconRuler />
</ActionIcon>
</ActionIcon.Group>
</Flex>
}} />
</Tooltip>
</div>
</div>
)
}

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 useSWR from 'swr'
import { fetcher } from '../../http/axiosInstance'
@ -14,11 +13,9 @@ const ObjectData = (object_data: IObjectData) => {
)
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}
</Flex>
</div>
)
}

View File

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

View File

@ -1,10 +1,10 @@
import { Flex, LoadingOverlay } from '@mantine/core';
import { IObjectParam } from '../../../interfaces/objects';
import ObjectParameter from '../ObjectParameter';
import useSWR from 'swr';
import { BASE_URL } from '../../../constants';
import { fetcher } from '../../../http/axiosInstance';
import { useObjectsStore } from '../../../store/objects';
import { Spinner, Table, TableBody } from '@fluentui/react-components';
const ObjectParameters = ({
map_id
@ -24,40 +24,59 @@ const ObjectParameters = ({
)
return (
<Flex gap={'sm'} direction={'column'} pos='relative'>
<LoadingOverlay visible={valuesValidating} />
{Array.isArray(valuesData) &&
Object.entries(
valuesData.reduce((acc, param) => {
if (!acc[param.id_param]) {
acc[param.id_param] = [];
}
acc[param.id_param].push(param);
return acc;
}, {} as Record<string, IObjectParam[]>)
).map(([id_param, params]) => {
// Step 1: Sort the parameters by date_s (start date) and date_po (end date)
const sortedParams = (params as IObjectParam[]).sort((b, a) => {
const dateA = new Date(a.date_s || 0);
const dateB = new Date(b.date_s || 0);
return dateA.getTime() - dateB.getTime();
});
<div style={{ display: 'flex', gap: '1rem', flexDirection: 'column', position: 'relative' }}>
{(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>
)}
return sortedParams.length > 1 ? (
sortedParams.map((param: IObjectParam) => {
if (param.date_po == null) {
return (
<ObjectParameter map_id={map_id} key={id_param} param={param} showLabel={false} />
<Table size='small'>
<TableBody>
{Array.isArray(valuesData) &&
Object.entries(
valuesData.reduce((acc, param) => {
if (!acc[param.id_param]) {
acc[param.id_param] = [];
}
acc[param.id_param].push(param);
return acc;
}, {} as Record<string, IObjectParam[]>)
).map(([id_param, params]) => {
// Step 1: Sort the parameters by date_s (start date) and date_po (end date)
const sortedParams = (params as IObjectParam[]).sort((b, a) => {
const dateA = new Date(a.date_s || 0);
const dateB = new Date(b.date_s || 0);
return dateA.getTime() - dateB.getTime();
});
return sortedParams.length > 1 ? (
sortedParams.map((param: IObjectParam) => {
if (param.date_po == null) {
return (
<ObjectParameter map_id={map_id} key={id_param} param={param} showLabel={false} />
)
}
}
)
}
}
)
) : (
<ObjectParameter map_id={map_id} key={id_param} param={sortedParams[0]} />
);
})
}
</Flex>
) : (
<ObjectParameter map_id={map_id} key={id_param} param={sortedParams[0]} />
);
})
}
</TableBody>
</Table>
</div>
)
}

View File

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

View File

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

View File

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

View File

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

View File

@ -18,6 +18,8 @@ import { measureStyleFunction, modifyStyle } from "./Measure/MeasureStyles";
import { getCurrentTool, getDraw, getDrawingLayerSource, getImageLayer, getMap, getMeasureClearPrevious, getMeasureDraw, getMeasureModify, getMeasureSource, getMeasureType, getOverlayLayerSource, getSnap, getTipPoint, getTranslate, PrintOrientation, setDraw, setFile, setMeasureDraw, setPolygonExtent, setRectCoords, setSnap, setTranslate } from "../../store/map";
import Collection from "ol/Collection";
import { SketchCoordType } from "ol/interaction/Draw";
import VectorImageLayer from "ol/layer/VectorImage";
import VectorSource from "ol/source/Vector";
const calculateAngle = (coords: [number, number][]) => {
const [start, end] = coords;
@ -482,18 +484,24 @@ export const addInteractions = (
}
}
export const zoomToFeature = (map_id: string, feature: Feature) => {
const geometry = feature.getGeometry()
const extent = geometry?.getExtent()
export const zoomToFeature = (map_id: string, feature: Feature | undefined) => {
if (feature) {
const geometry = feature.getGeometry()
const extent = geometry?.getExtent()
if (getMap(map_id) && extent) {
getMap(map_id)?.getView().fit(extent, {
duration: 300,
maxZoom: 19,
})
if (getMap(map_id) && extent) {
getMap(map_id)?.getView().fit(extent, {
duration: 300,
maxZoom: 19,
})
}
}
}
export const getFeatureByEntityId = (entity_id: number, layer: VectorImageLayer<Feature<Geometry>, VectorSource<Feature<Geometry>>>) => {
return layer.getSource()?.getFeatures().find(feature => feature.getProperties().entity_id === entity_id)
}
// Function to save features to localStorage
export const saveFeatures = (map_id: string) => {
const features = getDrawingLayerSource(map_id).getFeatures()

View File

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

View File

@ -1,11 +1,10 @@
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 SignUp from "../pages/auth/SignUp";
import PasswordReset from "../pages/auth/PasswordReset";
import ComponentTest from "../pages/ComponentTest";
import MonitorPage from "../pages/MonitorPage";
import Settings from "../pages/Settings";
import Main from "../pages/Main";
import Users from "../pages/Users";
import Roles from "../pages/Roles";
import Documents from "../pages/Documents";
@ -17,6 +16,7 @@ import PrintReport from "../pages/PrintReport";
import DBManager from "../pages/DBManager";
import MapLineTest from "../components/map/MapLineTest";
import FuelPage from "../pages/Fuel";
import { Building24Color, Cloud24Color, Database24Color, Document24Color, Form24Color, Map24Filled, Map24Regular, PeopleList24Color, Shield24Color } from "@fluentui/react-icons"
// Определение страниц с путями и компонентом для рендера
@ -60,8 +60,10 @@ const pages = [
{
label: "Главная",
path: "/",
icon: <IconHome />,
component: <Main />,
icon: <Map24Filled />,
component: <MapTest />,
// icon: <Home24Color />,
// component: <Main />,
drawer: true,
dashboard: true,
enabled: true,
@ -69,7 +71,7 @@ const pages = [
{
label: "Пользователи",
path: "/user",
icon: <IconUsers />,
icon: <PeopleList24Color />,
component: <Users />,
drawer: true,
dashboard: true,
@ -78,7 +80,7 @@ const pages = [
{
label: "Роли",
path: "/role",
icon: <IconShield />,
icon: <Shield24Color />,
component: <Roles />,
drawer: true,
dashboard: true,
@ -87,7 +89,7 @@ const pages = [
{
label: "Документы",
path: "/documents",
icon: <IconFiles />,
icon: <Document24Color />,
component: <Documents />,
drawer: true,
dashboard: true,
@ -96,7 +98,7 @@ const pages = [
{
label: "Отчеты",
path: "/reports",
icon: <IconReport />,
icon: <Form24Color />,
component: <Reports />,
drawer: true,
dashboard: true,
@ -105,7 +107,7 @@ const pages = [
{
label: "Серверы",
path: "/servers",
icon: <IconServer />,
icon: <Cloud24Color />,
component: <Servers />,
drawer: true,
dashboard: true,
@ -114,29 +116,29 @@ const pages = [
{
label: "Котельные",
path: "/boilers",
icon: <IconBuildingFactory2 />,
icon: <Building24Color />,
component: <Boilers />,
drawer: true,
dashboard: true,
enabled: true,
},
{
label: "ИКС",
path: "/map-test",
icon: <IconMap />,
component: <MapTest />,
drawer: true,
dashboard: true,
enabled: true,
},
// {
// label: "ИКС",
// path: "/map-test",
// icon: <Map24Filled />,
// component: <MapTest />,
// drawer: true,
// dashboard: true,
// enabled: true,
// },
{
label: "Map line test",
path: "/map-line-test",
icon: <IconMap />,
icon: <Map24Regular />,
component: <MapLineTest />,
drawer: true,
dashboard: true,
enabled: true,
enabled: false,
},
{
label: "Монитор",
@ -168,11 +170,11 @@ const pages = [
{
label: "Тест БД",
path: "/db-manager",
icon: <IconComponents />,
icon: <Database24Color />,
component: <DBManager />,
drawer: true,
dashboard: true,
enabled: true,
enabled: false,
},
{
label: 'Fuel',

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

@ -5,6 +5,16 @@ import Map from "ol/Map";
import { Coordinate } from "ol/coordinate";
import { Mode } from "../store/map";
export interface IRegion {
id: number
name: string
}
export interface IDistrict {
id: number
name: string
}
export interface IFigure {
object_id: string,
figure_type_id: number,

View File

@ -1,16 +1,56 @@
import { AppShell, Avatar, Burger, Button, Flex, Group, Image, Menu, NavLink, rem, Text, useMantineColorScheme } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
import { Outlet, useNavigate } from 'react-router-dom';
import { IconChevronDown, IconLogout, IconSettings, IconMoon, IconSun } from '@tabler/icons-react';
import { Outlet, useLocation, useNavigate } from 'react-router-dom';
import { IconLogout, IconSettings, IconMoon, IconSun, IconMenu2, IconUser, IconLogin } from '@tabler/icons-react';
import { getUserData, logout, useAuthStore } from '../store/auth';
import { useEffect, useState } from 'react';
import { UserData } from '../interfaces/auth';
import { pages } from '../constants/app';
import { Button, Image, makeStyles, Menu, MenuButton, MenuItem, MenuList, MenuPopover, MenuTrigger, Text } from '@fluentui/react-components';
import { setColorScheme, useAppStore } from '../store/app';
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() {
const [mobileOpened, { toggle: toggleMobile }] = useDisclosure()
const [desktopOpened, { toggle: toggleDesktop }] = useDisclosure(false)
const navigate = useNavigate()
const location = useLocation()
const { colorScheme } = useAppStore()
const getPageTitle = () => {
const currentPath = location.pathname
@ -31,116 +71,87 @@ function DashboardLayout() {
}
}, [authStore])
const { colorScheme, setColorScheme } = useMantineColorScheme();
const classes = useStyles()
const [navbarOpen, setNavbarOpen] = useState(true)
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>
<div className={classes.root}>
<div className={classes.header}>
<div style={{
display: 'flex',
width: '100%',
alignItems: 'center',
gap: '0.75rem',
padding: '0.5rem 0.5rem 0.5rem 0.25rem',
}}>
<Button appearance='subtle' onClick={() => setNavbarOpen(!navbarOpen)} icon={<IconMenu2 />} />
<Group w='auto'>
<Text fw='600'>{getPageTitle()}</Text>
</Group>
<Text weight='bold' size={400}>
{getPageTitle()}
</Text>
<Group id='header-portal' w='auto' ml='auto'>
<div id='header-portal' style={{ marginLeft: 'auto' }}>
</Group>
</div>
<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()
<Menu positioning={{ autoSize: true }}>
<MenuTrigger>
<MenuButton appearance='transparent' icon={authStore.isAuthenticated ? <IconUser /> : <IconSettings />}>{authStore.isAuthenticated && `${userData?.name} ${userData?.surname}`}</MenuButton>
</MenuTrigger>
<MenuPopover>
<MenuList>
{!authStore.isAuthenticated && <MenuItem icon={<IconLogin />} onClick={() => navigate('/auth/signin')}>Войти</MenuItem>}
<MenuItem icon={colorScheme === 'dark' ? <IconMoon /> : <IconSun />} onClick={() => colorScheme === 'dark' ? setColorScheme('light') : setColorScheme('dark')}>Тема: {colorScheme === 'dark' ? 'тёмная' : 'светлая'}</MenuItem>
{authStore.isAuthenticated && <MenuItem icon={<IconSettings />} onClick={() => navigate('/settings')}>Настройки профиля</MenuItem>}
{authStore.isAuthenticated && <MenuItem icon={<IconLogout />} onClick={() => {
logout()
if (pages.find(page => page.path === '/auth/signin')?.enabled) {
navigate("/auth/signin")
}}
leftSection={<IconLogout style={{ width: rem(16), height: rem(16) }} stroke={1.5} />}
>
Выход
</Menu.Item>
}
}}>Выход</MenuItem>}
<MenuItem icon={<Image src={'/logo2.svg'} width={24} />}>
0.1.0
</MenuItem>
</MenuList>
</MenuPopover>
</Menu>
</div>
</div>
<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'}>
<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) => (
<Button key={item.path} style={{ paddingLeft: '0.5rem', flexShrink: 0, flexWrap: 'nowrap', textWrap: 'nowrap', borderRadius: 0 }} appearance={location.pathname === item.path ? 'primary' : 'subtle'} onClick={() => navigate(item.path)}>
<div style={{ display: 'flex', }}>
{item.icon}
</div>
<div style={{
display: 'flex',
justifyContent: 'flex-start',
width: '100%',
overflow: 'hidden',
marginLeft: '1rem',
}}>
{item.label}
</div>
</Button>
))}
</div>
<div className={classes.content}>
<Outlet />
</Flex>
</AppShell.Main>
</AppShell>
</div>
</div>
</div>
)
}

View File

@ -1,10 +1,119 @@
import { Flex } from "@mantine/core";
import { makeStyles } from "@fluentui/react-components";
import { Outlet } from "react-router-dom";
const useStyles = makeStyles({
root: {
display: 'flex',
justifyContent: 'center',
height: '100%',
width: '100%'
}
})
export default function MainLayout() {
const classes = useStyles()
return (
<Flex align='center' justify='center' h='100%' w='100%'>
<Outlet />
</Flex>
<div className={classes.root}>
<Background />
<div style={{ position: 'absolute', inset: 0, display: 'flex', width: '100vw', height: '100vh' }}>
<Outlet />
</div>
</div>
)
}
const Background = () => {
return (
<svg id="corp-bg" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1440 900" preserveAspectRatio="xMidYMid slice" style={{ width: '100%', height: '100vh', display: 'block' }}>
<defs>
<style>
{`:root {
--c1: #0f4c81; /* deep blue */
--c2: #1fb6ff; /* bright cyan */
--c3: #7bd389; /* muted green */
--muted: rgba(255,255,255,0.06);
}`}
</style>
<linearGradient id="grad-main" x1="0" x2="1" y1="0" y2="1">
<stop offset="0" stop-color="var(--c2)" stop-opacity="0.95" />
<stop offset="0.45" stop-color="var(--c1)" stop-opacity="0.95" />
<stop offset="1" stop-color="var(--c3)" stop-opacity="0.9" />
</linearGradient>
<filter id="f-blur">
<feGaussianBlur stdDeviation="40" result="b" />
<feColorMatrix type="matrix"
values="1 0 0 0 0
0 1 0 0 0
0 0 1 0 0
0 0 0 0.6 0" in="b" />
</filter>
<pattern id="dots" width="20" height="20" patternUnits="userSpaceOnUse">
<rect width="100%" height="100%" fill="none" />
<circle cx="2" cy="2" r="1.1" fill="white" fill-opacity="0.06" />
</pattern>
<pattern id="diagonal" width="12" height="12" patternUnits="userSpaceOnUse" patternTransform="rotate(20)">
<rect width="12" height="12" fill="transparent" />
<path d="M0 0 L0 1" stroke="white" stroke-opacity="0.02" stroke-width="1" />
</pattern>
<radialGradient id="spot" cx="70%" cy="20%" r="50%">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.06" />
<stop offset="1" stop-color="#ffffff" stop-opacity="0" />
</radialGradient>
<clipPath id="blob-clip">
<path id="blobPath"
d="M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z" />
</clipPath>
</defs>
<rect width="100%" height="100%" fill="url(#grad-main)" />
<g filter="url(#f-blur)" opacity="0.95">
<path d="M-60 150 C180 40 420 10 700 120 C1040 260 1180 520 960 760 C760 980 420 920 140 800 C-80 700 -40 300 -60 150 Z"
fill="white" fill-opacity="0.02" />
<path d="M1200 40 C1060 -20 900 10 760 120 C620 240 620 420 760 560 C900 700 1160 760 1320 640 C1460 540 1440 200 1200 40 Z"
fill="white" fill-opacity="0.03" />
</g>
<g transform="translate(60,30) scale(1.2)" opacity="0.9">
<g clip-path="url(#blob-clip)">
<rect width="100%" height="100%" fill="url(#spot)" />
</g>
<path id="morph" fill="white" fill-opacity="0.06">
<animate attributeName="d"
dur="18s"
repeatCount="indefinite"
values="
M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z;
M380 60C520 0 760 40 930 120C1080 220 1060 360 940 460C800 560 600 620 420 560C240 500 250 200 380 60Z;
M440 100C580 30 820 10 980 140C1130 280 1100 430 980 540C840 660 600 680 420 600C220 500 300 160 440 100Z;
M420 80C520 10 760 10 890 100C1040 210 1080 360 980 470C880 580 640 620 480 540C300 440 320 180 420 80Z"/>
</path>
</g>
<g opacity="0.12">
<rect x="40" y="60" width="360" height="620" rx="20" fill="url(#diagonal)" />
<rect x="980" y="80" width="380" height="540" rx="26" fill="url(#dots)" />
</g>
<g>
<rect x="80" y="420" width="1280" height="120" rx="60" fill="white" fill-opacity="0.02" transform="skewX(-18)" />
<rect x="-60" y="260" width="820" height="60" rx="30" fill="white" fill-opacity="0.015" transform="skewX(10)" />
</g>
<g stroke="white" stroke-opacity="0.06" stroke-width="1" fill="none">
<path d="M120 720 C320 600 520 540 720 580 C920 620 1120 760 1360 700" />
<path d="M40 620 C240 540 460 460 680 490 C920 520 1080 680 1360 640" />
</g>
<rect width="100%" height="100%" fill="black" opacity="0.02" />
</svg>
)
}

View File

@ -1,30 +1,12 @@
import "@fontsource/inter";
import '@mantine/core/styles.css';
import '@mantine/dates/styles.css';
import React from 'react'
import ReactDOM from 'react-dom/client'
import App from './App.tsx'
import './index.css'
import { createTheme, DEFAULT_THEME, MantineProvider, mergeMantineTheme } from '@mantine/core';
import 'dayjs/locale/ru';
import { DatesProvider } from "@mantine/dates";
const overrides = createTheme({
// Set this color to `--mantine-color-body` CSS variable
white: '#F0F0F0',
colors: {
// ...
},
})
const theme = mergeMantineTheme(DEFAULT_THEME, overrides);
ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
<React.StrictMode>
<MantineProvider theme={theme}>
<DatesProvider settings={{ locale: 'ru' }}>
<App />
</DatesProvider>
</MantineProvider>
<App />
</React.StrictMode>,
)

View File

@ -1,7 +1,7 @@
import { useEffect, useState } from 'react'
import { useBoilers } from '../hooks/swrHooks'
import { Stack, Text } from '@mantine/core'
import CustomTable from '../components/CustomTable'
import { Text } from '@fluentui/react-components'
function Boilers() {
const [boilersPage, setBoilersPage] = useState(1)
@ -25,82 +25,47 @@ function Boilers() {
}, [])
return (
<Stack w={'100%'} h={'100%'} p='sm'>
<Text size="xl" fw={600}>
<div style={{
display: 'flex',
flexDirection: 'column',
padding: '1rem',
width: '100%',
gap: '1rem'
}}>
<Text size={600} weight='bold'>
Котельные
</Text>
{boilers &&
<CustomTable data={boilers} columns={[
{
accessorKey: 'id_object',
name: 'id_object',
header: 'ID',
cell: (info) => info.getValue(),
type: 'string'
},
{
accessorKey: 'boiler_name',
name: 'boiler_name',
header: 'Название',
cell: (info) => info.getValue(),
type: 'string'
},
{
accessorKey: 'boiler_code',
name: 'boiler_code',
header: 'Код',
cell: (info) => info.getValue(),
type: 'string'
},
{
accessorKey: 'id_city',
name: 'id_city',
header: 'Город',
cell: (info) => info.getValue(),
type: 'dictionary'
},
{
accessorKey: 'activity',
name: 'activity',
header: 'Активен',
cell: (info) => info.getValue(),
type: 'boolean'
},
]} />
}
{/* {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>
</div>
)
}

View File

@ -1,11 +1,10 @@
import { Flex } from '@mantine/core'
import ServerHardware from '../components/ServerHardware'
const ComponentTest = () => {
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 />
</Flex>
</div>
)
}

View File

@ -1,36 +1,41 @@
import { Stack, Tabs } from '@mantine/core'
import useSWR from 'swr'
import { BASE_URL } from '../constants'
import { fetcher } from '../http/axiosInstance'
import { useState } from 'react'
import CustomTable from '../components/CustomTable'
import { Tab, TabList } from '@fluentui/react-components'
const DBManager = () => {
const { data: tablesData } = useSWR(`/db/tables`, (key) => fetcher(key, BASE_URL.ems), {
revalidateOnFocus: false
})
const [selectedTab, setSelectedTab] = useState<string | unknown>(undefined)
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 &&
<Tabs w='100%' h='80%'>
<Stack h='100%'>
<Tabs.List>
<div style={{ width: '100%', height: '100%' }}>
<div style={{ display: 'flex', flexDirection: 'column', height: '100%' }}>
<TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
{tablesData.map(table => (
<Tabs.Tab key={table.tablename} value={table.tablename}>
<Tab key={table.tablename} value={table.tablename}>
{table.tablename}
</Tabs.Tab>
</Tab>
))}
</Tabs.List>
</TabList>
{tablesData.map(table => (
<Tabs.Panel h='100%' key={table.tablename} value={table.tablename} w='100%'>
<TableData tablename={table.tablename} />
</Tabs.Panel>
))}
</Stack>
<div style={{ width: '100%', height: '100%' }}>
{tablesData.map((table) => {
if (table.tablename === selectedTab)
return (
<TableData tablename={table.tablename} />
)
})}
</div>
</div>
</Tabs>
</div>
}
{/* <Card withBorder radius='sm'>
<Stack>
@ -42,7 +47,7 @@ const DBManager = () => {
</Grid>
</Stack>
</Card> */}
</Stack>
</div>
)
}
@ -65,9 +70,9 @@ const TableData = ({
{columnsData && rowsData && Array.isArray(columnsData) && Array.isArray(rowsData) && columnsData.length > 0 &&
<CustomTable data={rowsData} columns={columnsData.map(column => (
{
accessorKey: column.column_name,
name: 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 { IconMathMax, IconPlus, IconTableMinus, IconTablePlus } from "@tabler/icons-react";
import { FuelExpenseDto, FuelExpenseDtoHeaders, FuelLimitDto, FuelLimitDtoHeaders } from "../dto/fuel/fuel.dto";
import { IconMathMax, IconPlus, IconTableMinus } from "@tabler/icons-react";
import { FuelExpenseDtoHeaders, FuelLimitDtoHeaders } from "../dto/fuel/fuel.dto";
import useSWR from "swr";
import { fetcher } from "../http/axiosInstanceNest";
import { useEffect, useState } from "react";
import { useDisclosure } from "@mantine/hooks";
import { DateInput, DatePicker } from '@mantine/dates'
import { SubmitHandler, useForm } from "react-hook-form";
import { AgGridReact } from "ag-grid-react";
import { AllCommunityModule, ColDef, ModuleRegistry } from 'ag-grid-community'
import { CalendarStrings, DatePicker, defaultDatePickerStrings } from "@fluentui/react-datepicker-compat"
import { Button, Dialog, DialogSurface, DialogTitle, DialogTrigger, Field, Input, Spinner, Tab, TabList } from "@fluentui/react-components";
import { useAppStore } from "../store/app";
ModuleRegistry.registerModules([AllCommunityModule])
@ -92,11 +92,9 @@ export default function FuelPage() {
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 { colorScheme } = useMantineColorScheme()
const { colorScheme } = useAppStore()
useEffect(() => {
if (colorScheme === 'dark') {
@ -108,63 +106,75 @@ export default function FuelPage() {
return (
<>
<ModalCreate openedCreateModal={openedCreateModal} closeCreateModal={closeCreateModal} currentTab={currentTab} />
<Tabs defaultValue={tables[0].value} w='100%' onChange={(tab) => setCurrentTab(tables.find(table => table.value === tab) || tables[0])}>
<Tabs.List>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }}>
<TabList defaultValue={tables[0].value} selectedValue={currentTab.value}>
{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}
</Tabs.Tab>
</Tab>
))}
</Tabs.List>
</TabList>
<Flex p='sm'>
<Button leftSection={<IconPlus />} onClick={openCreateModal}>
Добавить
</Button>
</Flex>
<div style={{ display: 'flex', padding: '1rem' }}>
<Dialog>
<DialogTrigger>
<Button
appearance='primary'
icon={<IconPlus />}
style={{ flexShrink: 0 }}
>
Добавить
</Button>
</DialogTrigger>
{tables.map((table, index) => (
<Tabs.Panel key={index} value={table.value} w='100%' h='100%'>
{isLoading ?
<Flex w='100%' justify={'center'} p='md'>
<Loader />
</Flex>
:
<>
<AgGridReact
//rowData={data}
rowData={[
Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {}),
Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {})
]}
columnDefs={Object.keys(table.headers).map((header) => ({
field: header
})) as ColDef[]}
defaultColDef={{
flex: 1,
}}
/>
</>
}
</Tabs.Panel>
))}
</Tabs>
<DialogSurface>
<DialogTitle>Добавление объекта</DialogTitle>
<ModalCreate currentTab={currentTab} />
</DialogSurface>
</Dialog>
</div>
{tables.map((table, index) => {
if (table.value === currentTab.value) {
return (
isLoading ?
<div style={{ display: 'flex', width: '100%', justifyContent: 'center', padding: '1rem' }}>
<Spinner />
</div>
:
<div style={{ width: '100%', height: '100%', padding: '1rem' }}>
<AgGridReact
key={index}
//rowData={data}
rowData={[
Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {}),
Object.keys(table.headers).reduce((obj, key) => ({ ...obj, [key]: 'test' }), {})
]}
columnDefs={Object.keys(table.headers).map((header) => ({
field: header
})) as ColDef[]}
defaultColDef={{
flex: 1,
}}
/>
</div>
)
}
}
)}
</div>
</>
)
}
const ModalCreate = ({
openedCreateModal,
closeCreateModal,
currentTab
}: {
openedCreateModal: boolean
closeCreateModal: () => void
currentTab: ITableSchema
}) => {
const { register, handleSubmit, reset, watch, formState: { errors, isSubmitting, dirtyFields, isValid } } = useForm({
const { register, handleSubmit,
//formState: { errors, isSubmitting, dirtyFields, isValid }
} = useForm({
mode: 'onChange',
})
@ -172,36 +182,72 @@ const ModalCreate = ({
console.log('Values to submit:', values)
}
const localizedStrings: CalendarStrings = {
...defaultDatePickerStrings,
days: [
'Воскресенье',
'Понедельник',
'Вторник',
'Среда',
'Четверг',
'Пятница',
'Суббота'
],
shortDays: ['Вс', 'Пн', 'Вт', 'Ср', 'Чт', 'Пт', 'Сб'],
months: [
"Январь", "Февраль", "Март", "Апрель", "Май", "Июнь", "Июль", "Август", "Сентябрь", "Ноябрь", "Декабрь"
],
shortMonths: [
"Янв",
"Фев",
"Мар",
"Апр",
"Май",
"Июн",
"Июл",
"Авг",
"Сен",
"Ноя",
"Дек",
]
}
return (
<Modal withinPortal opened={openedCreateModal} onClose={closeCreateModal}>
<LoadingOverlay visible={isSubmitting} />
<Flex direction='column' gap='sm' component='form' onSubmit={handleSubmit(onSubmit)}>
{currentTab.post_include.map((header, index) => {
switch (header.field_type) {
case 'date':
return (
<DateInput label={header.field} />
)
case 'text':
return (
<TextInput key={index} label={header.field} {...register(header.field, {
<form style={{ display: 'flex', flexDirection: 'column', gap: '1rem' }} onSubmit={handleSubmit(onSubmit)}>
{currentTab.post_include.map((header, index) => {
switch (header.field_type) {
case 'date':
return (
<Field key={index} label={header.field}>
<DatePicker
strings={localizedStrings}
{...register(header.field, {
required: true
})} />
</Field>
)
case 'text':
return (
<Field key={index} label={header.field}>
<Input {...register(header.field, {
required: true
})} />
)
default:
return (
<TextInput key={index} label={header.field} {...register(header.field, {
</Field>
)
default:
return (
<Field key={index} label={header.field}>
<Input {...register(header.field, {
required: true
})} />
)
}
})}
</Field>
)
}
})}
<Button mt='xl' type='submit'>
Добавить
</Button>
</Flex>
</Modal>
<Button style={{ marginTop: '2rem' }} appearance="primary" type='submit'>
Добавить
</Button>
</form>
)
}

View File

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

View File

@ -1,82 +1,100 @@
import { Container, Stack, Tabs } from '@mantine/core'
import MapComponent from '../components/map/MapComponent'
import { useEffect } from 'react'
import { initializeObjectsState } from '../store/objects'
import { deleteMapTab, setCurrentTab, useAppStore } from '../store/app'
import { initializeMapState, useMapStore } from '../store/map'
import { v4 as uuidv4 } from 'uuid'
import { Tab, TabList } from "@fluentui/react-tabs";
import MapComponent from "../components/map/MapComponent";
import { DragDropContext, Droppable, Draggable } from "@hello-pangea/dnd";
import {
useAppStore,
setCurrentTab,
deleteMapTab,
addMapTab,
reorderTabs,
} from "../store/app";
import { initializeMapState, useMapStore } from "../store/map";
import { initializeObjectsState } from "../store/objects";
import { Button } from "@fluentui/react-components";
import { Add12Filled, Dismiss12Filled, Map16Regular } from "@fluentui/react-icons";
function MapTest() {
const { mapTab, currentTab } = useAppStore()
const { currentTab, tabOrder } = useAppStore()
const { id } = useMapStore()
const tabs = [
{
id: uuidv4(),
year: 2018,
region: 11,
district: 146,
},
// {
// id: uuidv4(),
// year: 2023,
// region: 11,
// district: 146,
// },
]
useEffect(() => {
tabs.map(tab => useAppStore.setState((state) => {
initializeObjectsState(tab.id, tab.region, tab.district, null, tab.year)
initializeMapState(tab.id)
return {
mapTab: {
...state.mapTab,
[tab.id]: {
year: tab.year,
region: tab.region,
district: tab.district
}
}
}
}))
setCurrentTab(tabs[0].id)
return () => {
tabs.map(tab => deleteMapTab(tab.id))
}
}, [])
const handleDragEnd = (result: any) => {
if (!result.destination) return
reorderTabs(result.source.index, result.destination.index)
}
return (
<Container fluid w='100%' pos='relative' p={0}>
<Tabs h='100%' variant='default' value={currentTab} onChange={setCurrentTab} keepMounted={true}>
<Stack gap={0} h='100%'>
<Tabs.List>
{Object.entries(mapTab).map(([key]) => (
<Tabs.Tab value={key} key={key}>
{id[key]?.mapLabel}
</Tabs.Tab>
))}
</Tabs.List>
<div style={{ height: "100%", width: "100%", position: "relative" }}>
<div style={{ display: "flex", flexDirection: "column", height: "100%" }}>
<DragDropContext onDragEnd={handleDragEnd}>
<Droppable droppableId="tabs" direction="horizontal">
{(provided) => (
<TabList
ref={provided.innerRef}
{...provided.droppableProps}
size="small"
selectedValue={currentTab}
onTabSelect={(_, data) => setCurrentTab(data.value as string)}
style={{ borderBottom: '1px solid var(--colorNeutralShadowKey)' }}
onDragStart={(e) => {
e.stopPropagation(); // stop TabList from also handling it
}}
onDrag={(e) => e.stopPropagation()}
>
{tabOrder.map((key, index) => (
<Draggable disableInteractiveElementBlocking draggableId={key} index={index} key={key}>
{(dragProvided) => (
<div
ref={dragProvided.innerRef}
{...dragProvided.draggableProps}
{...dragProvided.dragHandleProps}
>
<Tab value={key} icon={<Map16Regular />}>
{id[key]?.mapLabel ?? `Tab ${key}`}
<Button
as='a'
style={{ marginLeft: '0.5rem' }}
size="small"
icon={<Dismiss12Filled />}
appearance="subtle"
onClick={(e) => {
e.stopPropagation()
deleteMapTab(key)
}}
/>
</Tab>
</div>
)}
</Draggable>
))}
{Object.entries(mapTab).map(([key]) => (
<Tabs.Panel value={key} key={key} h='100%' pos='relative'>
<MapComponent
key={key}
id={key}
active={currentTab === key}
/>
{provided.placeholder}
</Tabs.Panel>
<Button
icon={<Add12Filled />}
title="Открыть новую вкладку"
appearance="subtle"
onClick={() => {
const newId = addMapTab();
initializeObjectsState(newId, null, null, null, null);
initializeMapState(newId);
}}
/>
</TabList>
)}
</Droppable>
</DragDropContext>
<div style={{ flexGrow: 1, position: "relative" }}>
{tabOrder.map((key) => (
<div key={key} style={{ height: "100%", position: "relative", display: currentTab === key ? 'unset' : 'none' }}>
<MapComponent id={key} active={currentTab === key} />
</div>
))}
</Stack>
</Tabs>
</Container>
</div>
</div>
</div>
)
}
export default MapTest
export default MapTest;

View File

@ -1,5 +1,5 @@
import { Card } from '@fluentui/react-components';
import { useEffect, useState } from 'react'
import { Card, Flex } from '@mantine/core';
function CardComponent({
url,
@ -7,10 +7,10 @@ function CardComponent({
}: { url: string, is_alive: boolean }) {
return (
<Card>
<Flex p='sm' direction='column'>
<div>
<p>{url}</p>
<p>{JSON.stringify(is_alive)}</p>
</Flex>
</div>
</Card>
)
}
@ -38,11 +38,15 @@ export default function MonitorPage() {
return (
<div>
<Flex direction='column' gap='sm'>
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
{servers.length > 0 && servers.map((server: { name: string, status: boolean }) => (
<CardComponent url={server.name} is_alive={server.status} />
))}
</Flex>
</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";
const useStyles = makeStyles({
root: {
display: 'flex',
flexDirection: 'column',
alignItems: 'center'
}
})
export default function NotFound() {
const classes = useStyles()
return (
<Flex w={'100%'} h={'100%'} p='sm' gap='sm' align='center' justify='center'>
<Flex direction='column' gap='sm' align='center'>
<div style={{
width: '100%',
height: '100%',
}}>
<div className={classes.root}>
<IconError404 size={100} />
<Text size="xl" fw={500} ta='center'>
<Text size={500} weight='medium' align='center'>
Запрашиваемая страница не найдена.
</Text>
</Flex>
</Flex>
</div>
</div>
)
}

View File

@ -1,10 +1,10 @@
import { ActionIcon, Button, Flex, Group, Stack, Text, TextInput } from "@mantine/core"
import { useEffect, useState } from "react"
import createReport, { listCommands } from 'docx-templates'
import { Dropzone, IMAGE_MIME_TYPE } from '@mantine/dropzone'
import { IconFileTypeDocx, IconPlus, IconUpload, IconX } from "@tabler/icons-react"
import { IconPlus, IconX } from "@tabler/icons-react"
import { CommandSummary } from "docx-templates/lib/types"
import { Control, Controller, FieldValues, SubmitHandler, useFieldArray, useForm, UseFormRegister } from "react-hook-form"
import { Button, Field, Input, Text, tokens, useId } from "@fluentui/react-components"
import { ArrowUploadRegular } from "@fluentui/react-icons"
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">
@ -1024,11 +1024,11 @@ const FormLoop = ({
})
return (
<Stack align="center">
<Stack w='100%' key={command.code}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div style={{ display: 'flex', flexDirection: 'column', width: '100%' }} key={command.code}>
{
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.map(c =>
renderCommand(
@ -1040,30 +1040,30 @@ const FormLoop = ({
`${command.code}.${index}.${c.code}`
)
)}
<Button variant='subtle' onClick={() => {
<Button appearance='subtle' onClick={() => {
remove(index)
}}>
<IconX />
</Button>
</Flex>
</div>
))
}
</Stack>
</div>
<ActionIcon onClick={() => {
<Button icon={<IconPlus />} onClick={() => {
if (command.children) {
append(command.children.map(c => c.code).reduce((acc, key) => {
acc[key] = '';
return acc;
}, {} as Record<string, string>))
}
}}>
<IconPlus />
</ActionIcon>
</Stack>
}} />
</div>
)
}
const IMAGE_MIME_TYPE = ["image/png", "image/jpeg", "image/jpg", "image/webp"];
const renderCommand = (
control: Control<FieldValues, any>,
register: UseFormRegister<FieldValues>,
@ -1074,61 +1074,82 @@ const renderCommand = (
) => {
if (command.type === 'INS') {
return (
<TextInput
label={label}
key={key}
{...register(name)}
/>
<Field label={label}
key={key}>
<Input {...register(name)} />
</Field>
)
}
if (command.type === 'IMAGE') {
const inputId = useId("file-input");
return (
<Stack gap={0}>
<Text size='sm' fw={500}>{command.code}</Text>
<div style={{ display: 'flex', flexDirection: 'column' }}>
<Text size={200} weight="semibold">{command.code}</Text>
<Controller
key={key}
name={name}
control={control}
render={({ field: { onChange } }) => (
<Dropzone
accept={IMAGE_MIME_TYPE}
maxSize={5 * 1024 ** 2}
onReject={(files) => console.log('rejected files', files)}
onDrop={(files) => {
console.log(files[0])
files[0].arrayBuffer().then(res => {
onChange({
width: 6,
height: 6,
data: new Uint8Array(res),
extension: files[0]?.path?.match(/\.[^.]+$/)?.[0] || ""
})
})
}}
maxFiles={1}
>
<Group justify="center" gap="xl" mih={220} style={{ pointerEvents: 'none' }}>
<Dropzone.Accept>
<IconUpload size={52} color="var(--mantine-color-blue-6)" stroke={1.5} />
</Dropzone.Accept>
<Dropzone.Reject>
<IconX size={52} color="var(--mantine-color-red-6)" stroke={1.5} />
</Dropzone.Reject>
<Dropzone.Idle>
<IconFileTypeDocx size={52} color="var(--mantine-color-dimmed)" stroke={1.5} />
</Dropzone.Idle>
render={({ field: { onChange } }) => {
const handleFiles = (files: FileList | null) => {
if (!files || files.length === 0) return;
const file = files[0];
if (!IMAGE_MIME_TYPE.includes(file.type)) {
console.log("Rejected file:", file);
return;
}
<div>
<Text size="xl" inline>
Перетащите файлы сюда или нажмите, чтобы выбрать их
</Text>
</div>
</Group>
</Dropzone>
)}
file.arrayBuffer().then((res) => {
onChange({
width: 6,
height: 6,
data: new Uint8Array(res),
extension: file.name.match(/\.[^.]+$/)?.[0] || "",
});
});
};
const handleDrop = (e: React.DragEvent<HTMLDivElement>) => {
e.preventDefault();
handleFiles(e.dataTransfer.files);
};
return (
<div
onDragOver={(e) => e.preventDefault()}
onDrop={handleDrop}
style={{
border: `2px dashed ${tokens.colorNeutralStroke1}`,
borderRadius: tokens.borderRadiusLarge,
minHeight: "220px",
display: "flex",
flexDirection: "column",
alignItems: "center",
justifyContent: "center",
gap: "1rem",
padding: "1rem",
textAlign: "center",
cursor: "pointer",
}}
onClick={() => document.getElementById(inputId)?.click()}
>
<input
id={inputId}
type="file"
accept={IMAGE_MIME_TYPE.join(",")}
style={{ display: "none" }}
onChange={(e) => handleFiles(e.target.files)}
/>
<ArrowUploadRegular fontSize={40} color={tokens.colorBrandForeground1} />
<Text size={300}>
Перетащите изображение сюда или нажмите, чтобы выбрать
</Text>
</div>
);
}}
/>
</Stack>
</div>
)
}
}
@ -1240,21 +1261,21 @@ const TemplateForm = ({
if (commandList) {
return (
<form onSubmit={handleSubmit(onSubmit)}>
<Stack>
<div style={{ display: 'flex', flexDirection: 'column' }}>
{commandList.map(command => {
if (command.type === 'FOR') {
return (
<Stack gap={0} key={command.code}>
<Text size='sm' fw={500}>{command.code}</Text>
<div style={{ display: 'flex', flexDirection: 'column' }} key={command.code}>
<Text size={200} weight='semibold'>{command.code}</Text>
<FormLoop control={control} register={register} command={command} />
</Stack>
</div>
)
} else {
return renderCommand(control, register, command, command.code, command.code, command.code)
}
})}
<Button ml='auto' w='fit-content' type='submit'>Сохранить</Button>
</Stack>
<Button style={{ marginLeft: 'auto', width: 'fit-content' }} type='submit'>Сохранить</Button>
</div>
</form>
)
}
@ -1262,13 +1283,13 @@ const TemplateForm = ({
const PrintReport = () => {
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" />
<Flex gap='sm'>
<div style={{ display: 'flex', gap: '1rem' }}>
<Button onClick={handleGenerateExcel}>Сохранить в Excel</Button>
</Flex>
</Stack>
</div>
</div>
)
}

View File

@ -3,8 +3,8 @@ import { useCities, useReport, useReportExport } from "../hooks/swrHooks"
import { useDebounce } from "@uidotdev/usehooks"
import { ICity } from "../interfaces/fuel"
import { mutate } from "swr"
import { ActionIcon, Autocomplete, Badge, Button, CloseButton, Flex, ScrollAreaAutosize, Table } from "@mantine/core"
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() {
const [download, setDownload] = useState(false)
@ -40,99 +40,125 @@ export default function Reports() {
}
return (
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
<Flex component="form" gap={'sm'}>
<div style={{
width: '100%',
height: '100%',
padding: '1rem'
}}>
<form style={{
display: 'flex',
gap: '0.5rem'
}}>
{/* <SearchableSelect /> */}
<Autocomplete
placeholder="Населенный пункт"
flex={'1'}
data={cities ? cities.map((item: ICity) => ({ label: item.name, value: item.id.toString() })) : []}
onSelect={(e) => console.log(e.currentTarget.value)}
onChange={(value) => setSearch(value)}
onOptionSubmit={(value) => setSelectedOption(Number(value))}
rightSection={
search !== '' && (
<CloseButton
size="sm"
onMouseDown={(event) => event.preventDefault()}
onClick={() => {
setSearch('')
setSelectedOption(null)
}}
aria-label="Clear value"
/>
)
}
value={search}
/>
<Combobox clearable placeholder="Населенный пункт" onOptionSelect={(_, data) => {
setSelectedOption(Number(data.optionValue))
setSearch(data.optionText ?? "")
}} value={search} onChange={(e) => setSearch(e.currentTarget.value)}>
{cities && Array.isArray(cities) && cities.map((item: ICity) => (
<Option key={item.id} value={item.id.toString()}>
{item.name}
</Option>
))}
</Combobox>
<ActionIcon size='auto' variant='transparent' onClick={() => refreshReport()}>
<IconRefresh />
</ActionIcon>
<Button icon={<IconRefresh />} appearance="subtle" onClick={() => refreshReport()}>
</Button>
<Button disabled={!selectedOption} onClick={() => exportReport()}>
Экспорт
</Button>
</Flex>
</form>
{report &&
<Table highlightOnHover>
<Table.Thead>
<Table.Tr>
{[
{ field: 'id', headerName: '№', width: 70 },
...Object.keys(report).map(key => ({
field: key,
headerName: key.charAt(0).toUpperCase() + key.slice(1),
width: 150
}))
].map(column => (
<Table.Th key={column.headerName}>{column.headerName}</Table.Th>
))}
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{[...new Set(Object.keys(report).flatMap(key => Object.keys(report[key])))].map(id => {
const row: Record<string, unknown> = { id: Number(id) };
Object.keys(report).forEach(key => {
row[key] = report[key][id];
});
return (<Table.Tr key={row.id as number}>
{[
{ 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>
<div style={{
display: 'flex',
width: '100%',
overflow: 'auto'
}}>
{report &&
<ReportTable report={report} />
}
</div>
) : (
<Table.Td key={`${row.id}-${column.headerName}`}>
<Badge color="gray" fullWidth variant="light">
Отключен
</Badge>
</Table.Td>
)
)
}
return (
<Table.Td key={`${row.id}-${column.headerName}`}>{row[column.field] as string}</Table.Td>
)
})}
</Table.Tr>)
})}
</Table.Tbody>
</Table>
}
</ScrollAreaAutosize>
</div>
)
}
interface ReportType {
[key: string]: Record<string, unknown>;
}
function ReportTable({ report }: { report: ReportType }) {
// Build column definitions
const columns: TableColumnDefinition<any>[] = [
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) };
Object.keys(report).forEach((key) => {
row[key] = report[key][id];
});
return row;
});
return (
<DataGrid
items={items}
columns={columns}
sortable
focusMode='row_unstable'
resizableColumns
resizableColumnsOptions={{ autoFitColumns: false }}
size='extra-small'
>
<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 { CreateField } from '../interfaces/create'
import RoleService from '../services/RoleService'
import { Loader, Stack } from '@mantine/core'
import CustomTable from '../components/CustomTable'
import { Link, Spinner } from '@fluentui/react-components'
export default function Roles() {
const { roles, isError, isLoading } = useRoles()
@ -12,31 +12,64 @@ export default function Roles() {
{ key: 'description', headerName: 'Описание', type: 'string', required: false, defaultValue: '' },
]
if (isError) return <div>Произошла ошибка при получении данных.</div>
if (isLoading) return <Loader />
const handleError = (error: any) => {
if (error?.response?.status === 401) {
return (
<Link href="/auth/signin">
Войдите, чтобы продолжить
</Link>
)
} else {
return "Произошла ошибка при получении данных."
}
}
if (isError) return (
<div style={{ padding: '1rem' }}>
{handleError(isError)}
</div>
)
if (isLoading)
return (
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '1rem',
}}>
<Spinner />
</div>
)
return (
<Stack w={'100%'} h={'100%'} p='sm'>
<div style={{
width: '100%',
height: '100%',
padding: '1rem'
}} >
<CustomTable
createFields={createFields}
submitHandler={RoleService.createRole}
data={roles} columns={[
{
accessorKey: 'id',
name: 'id',
header: 'id',
cell: (info) => info.getValue(),
type: 'number'
},
{
accessorKey: 'name',
name: 'name',
header: 'Название',
cell: (info) => info.getValue(),
type: 'string'
},
{
accessorKey: 'description',
name: 'description',
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 ServerHardware from "../components/ServerHardware"
import ServerStorage from "../components/ServerStorages"
import { Flex, ScrollAreaAutosize, Tabs } from "@mantine/core"
import { Tab, TabList } from "@fluentui/react-components"
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 (
<ScrollAreaAutosize w={'100%'} h={'100%'} p='sm'>
<Flex gap='sm' direction='column'>
<Tabs value={currentTab} onChange={setCurrentTab}>
<Tabs.List>
<Tabs.Tab value="0">Серверы</Tabs.Tab>
<Tabs.Tab value="1">IP-адреса</Tabs.Tab>
<Tabs.Tab value="3">Hardware</Tabs.Tab>
<Tabs.Tab value="4">Storages</Tabs.Tab>
</Tabs.List>
<div style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
gap: '1rem',
padding: '1rem'
}}>
<TabList selectedValue={selectedTab} onTabSelect={(_, data) => setSelectedTab(data.value)}>
{tabs.map(tab => (
<Tab key={tab.id} value={tab.id}>{tab.name}</Tab>
))}
</TabList>
<Tabs.Panel value="0" pt='sm'>
<ServersView />
</Tabs.Panel>
<div>
{tabs.find(tab => tab.id === selectedTab)?.content}
</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 FormFields from "../components/FormFields"
import AuthService from "../services/AuthService"
import { Flex, ScrollAreaAutosize } from "@mantine/core"
export default function Settings() {
const { token } = useAuthStore()
@ -39,13 +38,20 @@ export default function Settings() {
]
return (
<ScrollAreaAutosize
w={'100%'}
h={'100%'}
p='sm'
<div
style={{
width: '100%',
height: '100%',
padding: '1rem'
}}
>
{currentUser &&
<Flex direction='column' gap='sm' w='100%'>
<div style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
gap: '1rem'
}}>
<FormFields
fields={profileFields}
defaultValues={currentUser}
@ -61,8 +67,8 @@ export default function Settings() {
submitHandler={(data) => AuthService.updatePassword({ id: currentUser.id, ...data })}
title="Смена пароля"
/>
</Flex>
</div>
}
</ScrollAreaAutosize>
</div>
)
}

View File

@ -3,8 +3,9 @@ import { IRole } from "../interfaces/role"
import { useEffect, useState } from "react"
import { CreateField } from "../interfaces/create"
import UserService from "../services/UserService"
import { Flex, Loader, Stack } from "@mantine/core"
import CustomTable from "../components/CustomTable"
import { Link, Spinner } from "@fluentui/react-components"
import { IUser } from "../interfaces/user"
export default function Users() {
const { users, isError, isLoading } = useUsers()
@ -13,12 +14,20 @@ export default function Users() {
const [roleOptions, setRoleOptions] = useState<{ label: string, value: string }[]>()
const [data, setData] = useState<IUser[]>([])
useEffect(() => {
if (Array.isArray(roles)) {
setRoleOptions(roles.map((role: IRole) => ({ label: role.name, value: role.id.toString() })))
}
}, [roles])
useEffect(() => {
if (users) {
setData(users)
}
}, [users])
const createFields: CreateField[] = [
{ key: 'email', headerName: 'E-mail', type: 'string', required: true, defaultValue: '' },
{ key: 'login', headerName: 'Логин', type: 'string', required: true, defaultValue: '' },
@ -28,65 +37,98 @@ export default function Users() {
{ key: 'password', headerName: 'Пароль', type: 'string', required: true, defaultValue: '' },
]
const handleError = (error: any) => {
if (error?.response?.status === 401) {
return (
<Link href="/auth/signin">
Войдите, чтобы продолжить
</Link>
)
} else {
return "Произошла ошибка при получении данных."
}
}
if (isError) return (
<div>
Произошла ошибка при получении данных.
<div style={{ padding: '1rem' }}>
{handleError(isError)}
</div>
)
if (isLoading) {
return (
<Flex direction='column' align='flex-start' gap='sm' p='sm'>
<Loader />
</Flex>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
width: '100%',
height: '100%',
padding: '1rem',
}}>
<Spinner />
</div>
)
}
return (
<Stack w={'100%'} h={'100%'} p='xs'>
{Array.isArray(roleOptions) &&
<div style={{
display: 'flex',
flexDirection: 'column',
width: '100%',
height: '100%',
padding: '1rem'
}}>
{Array.isArray(roleOptions) && Array.isArray(data) &&
<CustomTable
createFields={createFields}
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={[
{
accessorKey: 'email',
name: 'email',
header: 'E-mail',
cell: (info) => info.getValue(),
type: "string"
},
{
accessorKey: 'login',
name: 'login',
header: 'Логин',
cell: (info) => info.getValue(),
type: "string"
},
{
accessorKey: 'phone',
name: 'phone',
header: 'Телефон',
cell: (info) => info.getValue(),
type: "string"
},
{
accessorKey: 'name',
name: 'name',
header: 'Имя',
cell: (info) => info.getValue(),
type: "string"
},
{
accessorKey: 'surname',
name: 'surname',
header: 'Фамилия',
cell: (info) => info.getValue(),
type: "string"
},
{
accessorKey: 'is_active',
name: 'is_active',
header: 'Активен',
cell: (info) => info.getValue(),
type: "boolean"
},
{
accessorKey: 'role_id',
name: 'role_id',
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 { SubmitHandler, useForm } from 'react-hook-form';
import AuthService from '../../services/AuthService';
import { Button, Flex, Loader, Paper, Text, TextInput, Transition } from '@mantine/core';
import { IconCheck } from '@tabler/icons-react';
import { Button, Input, Spinner, Text } from '@fluentui/react-components';
interface PasswordResetProps {
email: string;
@ -11,7 +11,7 @@ interface PasswordResetProps {
function PasswordReset() {
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: {
email: ''
}
@ -31,65 +31,82 @@ function PasswordReset() {
}
return (
<Paper flex={1} maw='500' withBorder radius='md' p='xl'>
<Flex direction='column' gap='sm'>
<Text size="xl" fw={500}>
Восстановление пароля
</Text>
<div style={{
display: 'flex',
margin: 'auto',
flexDirection: 'column',
gap: '1rem',
maxWidth: '400px',
width: '100%',
height: 'min-content',
borderRadius: '1rem',
border: '1px solid #00000030',
padding: '2rem',
background: 'var(--colorNeutralBackground1)'
}}>
<Text size={600} weight='medium'>
Восстановление пароля
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
{!success &&
<Transition mounted={!success} transition='fade'>
{(styles) =>
<Flex style={styles} direction='column' gap={'md'}>
<Text>
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
</Text>
<form onSubmit={handleSubmit(onSubmit)}>
{!success &&
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
<Text>
Введите адрес электронной почты, на который будут отправлены новые данные для авторизации:
</Text>
<TextInput
label='E-mail'
required
{...register('email', { required: 'Введите E-mail' })}
error={errors.email?.message}
/>
<Input
placeholder='E-mail'
required
{...register('email', { required: 'Введите E-mail' })}
//error={errors.email?.message}
/>
<Flex gap='sm'>
<Button flex={1} type="submit" disabled={isSubmitting || watch('email').length == 0} variant='filled'>
{isSubmitting ? <Loader size={16} /> : 'Восстановить пароль'}
</Button>
<div style={{
display: 'flex',
width: '100%',
justifyContent: 'space-between'
}}>
<Button type="submit" disabled={isSubmitting || watch('email').length == 0} appearance='primary'>
{isSubmitting ? <Spinner /> : 'Восстановить пароль'}
</Button>
<Button flex={1} component='a' href="/auth/signin" type="button" variant='light'>
Назад
</Button>
</Flex>
<Button as='a' href="/auth/signin" type="button" appearance='subtle'>
Назад
</Button>
</div>
</Flex>
}
</Transition>
}
{success &&
<Transition mounted={!success} transition='scale'>
{(styles) =>
<Flex style={styles} direction='column' gap='sm'>
<Flex align='center' gap='sm'>
<IconCheck />
<Text>
На указанный адрес было отправлено письмо с новыми данными для авторизации.
</Text>
</Flex>
<Flex gap='sm'>
<Button component='a' href="/auth/signin" type="button">
Войти
</Button>
</Flex>
</Flex>
}
</Transition>
}
</form>
</Flex>
</Paper>
</div>
}
{success &&
<div style={{
display: 'flex',
flexDirection: 'column',
gap: '1rem'
}}>
<div style={{
display: 'flex',
alignItems: 'center',
gap: '1rem'
}}>
<IconCheck />
<Text>
На указанный адрес было отправлено письмо с новыми данными для авторизации.
</Text>
</div>
<div style={{ display: 'flex' }}>
<Button as='a' href="/auth/signin" type="button">
Войти
</Button>
</div>
</div>
}
</form>
</div>
)
}

View File

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

View File

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

View File

@ -1,43 +1,92 @@
import { create } from 'zustand';
import { create } from 'zustand'
import { v4 as uuidv4 } from 'uuid'
import { initializeObjectsState } from './objects'
import { initializeMapState } from './map'
export type Mode = 'edit' | 'view'
export type ColorScheme = 'light' | 'dark' | 'auto'
export interface AppState {
mapTab: Record<string, {
year: number | null,
region: number | null,
district: number | null
}>,
currentTab: string | null;
export interface MapTabState {
year: number | null
region: number | null
district: number | null
}
export const useAppStore = create<AppState>(() => ({
currentTab: null,
mapTab: {}
}))
export interface AppState {
colorScheme: ColorScheme
mapTab: Record<string, MapTabState>
tabOrder: string[] // 👈 defines tab order
currentTab: string | null
}
const getCurrentTab = () => useAppStore.getState().currentTab
const setCurrentTab = (id: string | null) => useAppStore.setState(() => ({ currentTab: id }))
export const useAppStore = create<AppState>(() => {
const firstId = uuidv4()
const setMapTabYear = (id: string, year: number | null) =>
initializeObjectsState(firstId, null, null, null, null)
initializeMapState(firstId)
return {
colorScheme: "auto",
currentTab: firstId,
mapTab: {
[firstId]: { year: null, region: null, district: null },
},
tabOrder: [firstId],
}
})
// getters/setters
export const getColorScheme = () => useAppStore.getState().colorScheme
export const setColorScheme = (colorScheme: ColorScheme) => {
useAppStore.setState(() => ({ colorScheme }))
localStorage.setItem('colorScheme', colorScheme.toString())
}
export const getCurrentTab = () => useAppStore.getState().currentTab
export const setCurrentTab = (id: string | null) => useAppStore.setState(() => ({ currentTab: id }))
export const setMapTabYear = (id: string, year: number | null) =>
useAppStore.setState((state) => ({
mapTab: {
...state.mapTab,
[id]: { ...state.mapTab[id], year },
},
}))
export const deleteMapTab = (id: string) =>
useAppStore.setState((state) => {
const { [id]: _, ...remainingTabs } = state.mapTab
const newOrder = state.tabOrder.filter((tid) => tid !== id)
return {
mapTab: {
...state.mapTab,
[id]: { ...state.mapTab[id], year: year }
}
mapTab: remainingTabs,
tabOrder: newOrder,
currentTab: newOrder.length > 0 ? newOrder[newOrder.length - 1] : null,
}
})
const deleteMapTab = (id: string) =>
useAppStore.setState((state) => {
const { [id]: _, ...remainingTabs } = state.mapTab;
return { mapTab: remainingTabs };
})
export const addMapTab = () => {
const id = uuidv4()
initializeObjectsState(id, null, null, null, null)
initializeMapState(id)
export {
deleteMapTab,
getCurrentTab,
setCurrentTab,
setMapTabYear
useAppStore.setState((state) => ({
mapTab: {
...state.mapTab,
[id]: { year: null, region: null, district: null },
},
tabOrder: [...state.tabOrder, id],
currentTab: id,
}))
return id
}
export const reorderTabs = (from: number, to: number) =>
useAppStore.setState((state) => {
const newOrder = Array.from(state.tabOrder)
const [moved] = newOrder.splice(from, 1)
newOrder.splice(to, 0, moved)
return { tabOrder: newOrder }
})

View File

@ -21,11 +21,12 @@ import { VectorImage } from 'ol/layer';
import { click, pointerMove } from 'ol/events/condition';
import { measureStyleFunction, modifyStyle } from '../components/map/Measure/MeasureStyles';
import MapBrowserEvent from 'ol/MapBrowserEvent';
import { transform } from 'ol/proj';
import { applyTransformations, calculateTransformations, fixedAspectRatioBox, zoomToFeature } from '../components/map/mapUtils';
import { setCurrentObjectId, setSelectedRegion } from './objects';
import { get, transform } from 'ol/proj';
import { applyTransformations, calculateTransformations, fixedAspectRatioBox, getGridCellPosition, zoomToFeature } from '../components/map/mapUtils';
import { getSelectedRegion, setCurrentObjectId, setSelectedDistrict, setSelectedRegion } from './objects';
import View from 'ol/View';
import { getPrintOrientation } from './print';
import { getDistrictsData, getRegionsData } from './regions';
export type Mode = 'edit' | 'view' | 'print'
@ -45,7 +46,8 @@ interface MapState {
currentX: number | undefined;
currentY: number | undefined;
currentCoordinate: Coordinate | null;
statusText: string;
statusText: string | null;
statusTextPosition: [number, number];
satMapsProvider: SatelliteMapsProvider;
selectedObjectType: number | null;
alignMode: boolean;
@ -72,7 +74,7 @@ interface MapState {
figuresLayer: VectorLayer<VectorSource>;
linesLayer: VectorLayer<VectorSource>;
regionsLayer: VectorImage;
citiesLayer: VectorLayer;
districtsLayer: VectorImage;
districtBoundLayer: VectorImage;
imageLayer: ImageLayer<ImageStatic>;
selectedArea: Feature | null;
@ -81,6 +83,7 @@ interface MapState {
measureModify: Modify;
overlayLayer: VectorLayer;
regionSelect: Select;
districtSelect: Select;
lineSelect: Select;
previousView: View | undefined | null;
printArea: Extent | null;
@ -115,11 +118,14 @@ export const initializeMapState = (
// Region select
const regionSelect = new Select({ condition: pointerMove, style: selectStyle, layers: (layer) => layer.get('type') === 'region' })
const districtSelect = new Select({ condition: pointerMove, style: selectStyle, layers: (layer) => layer.get('type') === 'district' })
// Line select
const lineSelect = new Select({ condition: click, style: highlightStyleRed, hitTolerance: hitTolerance, layers: (layer) => layer.get('type') === 'line', })
lineSelect.on('select', (e) => {
if (e.selected[0]) {
setCurrentObjectId(id, e.selected[0].get('object_id'))
zoomToFeature(id, e.selected[0])
}
})
@ -130,6 +136,7 @@ export const initializeMapState = (
figureSelect.on('select', (e) => {
if (e.selected[0]) {
setCurrentObjectId(id, e.selected[0].get('object_id'))
zoomToFeature(id, e.selected[0])
}
})
const figureHover = new Select({ condition: pointerMove, style: highlightStyleYellow, hitTolerance: hitTolerance, layers: (layer) => layer.get('type') === 'figure', })
@ -183,7 +190,7 @@ export const initializeMapState = (
const districtBoundLayer = new VectorImage({ style: new Style({ stroke: new Stroke({ color: 'red', width: 2 }), }) })
const citiesLayer = new VectorLayer({ source: new VectorSource(), properties: { id: uuidv4(), name: 'Города' } })
const districtsLayer = new VectorImage({ source: new VectorSource(), properties: { id: uuidv4(), name: 'Населенные пункты', type: 'district' } })
const linesLayer = new VectorLayer({
source: new VectorSource(),
@ -266,10 +273,10 @@ export const initializeMapState = (
baseLayer,
satLayer,
staticMapLayer,
regionsLayer,
districtBoundLayer,
citiesLayer,
linesLayer,
districtsLayer,
districtBoundLayer,
regionsLayer,
figuresLayer,
drawingLayer,
imageLayer,
@ -282,30 +289,48 @@ export const initializeMapState = (
})
map.addInteraction(regionSelect)
map.addInteraction(districtSelect)
map.addInteraction(lineSelect)
map.addInteraction(lineHover)
map.addInteraction(figureSelect)
map.addInteraction(figureHover)
// map.on('pointermove', function (e: MapBrowserEvent<UIEvent>) {
// setCurrentCoordinate(id, e.coordinate)
// const currentExtent = get('EPSG:3857')?.getExtent() as Extent
// const { tileX, tileY } = getGridCellPosition(e.coordinate[0], e.coordinate[1], currentExtent, Number(map?.getView().getZoom()?.toFixed(0)))
// setCurrentZ(id, Number(map?.getView().getZoom()?.toFixed(0)))
// setCurrentX(id, tileX)
// setCurrentY(id, tileY)
const pointerHandler = (e: MapBrowserEvent<UIEvent>) => {
setCurrentCoordinate(id, e.coordinate)
const currentExtent = get('EPSG:3857')?.getExtent() as Extent
const { tileX, tileY } = getGridCellPosition(e.coordinate[0], e.coordinate[1], currentExtent, Number(map?.getView().getZoom()?.toFixed(0)))
setCurrentZ(id, Number(map?.getView().getZoom()?.toFixed(0)))
setCurrentX(id, tileX)
setCurrentY(id, tileY)
// const pixel = map?.getEventPixel(e.originalEvent)
// if (pixel) {
// map?.forEachFeatureAtPixel(pixel, function (feature, layer) {
// if (layer.get('type') === 'region') {
// if (feature.get('entity_id')) {
// setStatusText(id, feature.get('entity_id'))
// }
// }
// })
// }
// })
const pixel = map?.getEventPixel(e.originalEvent)
if (pixel) {
let found = false;
map?.forEachFeatureAtPixel(pixel, (feature, layer) => {
if (layer.get("type") === "region" && feature.get("entity_id")) {
found = true
const name = getRegionsData()?.find(region => region.id === feature.get("entity_id"))?.name || feature.get("entity_id") || ""
setStatusText(id, name);
setStatusTextPosition(id, pixel[0], pixel[1]);
}
if (layer.get("type") === "district" && feature.get("entity_id")) {
found = true
const name = getDistrictsData()?.find(district => district.id === feature.get("entity_id"))?.name || feature.get("entity_id") || ""
setStatusText(id, name);
setStatusTextPosition(id, pixel[0], pixel[1]);
}
});
if (!found) {
setStatusText(id, null);
}
}
}
map.on('pointermove', pointerHandler)
map.on('click', function (e: MapBrowserEvent<UIEvent>) {
if (getAlignMode(id)) {
@ -332,11 +357,25 @@ export const initializeMapState = (
if (pixel) {
map?.forEachFeatureAtPixel(pixel, function (feature, layer) {
if (layer) {
if (layer.get('type') === 'region') {
if (layer.get('type') === 'region' && layer.getOpacity() !== 0) {
zoomToFeature(id, feature as Feature)
if (feature.get('entity_id')) {
setSelectedRegion(id, feature.get('entity_id'))
//regionsLayer.setVisible(false)
//regionsLayer.setOpacity(0)
map.removeInteraction(regionSelect)
}
}
if (layer.get('type') === 'district' && layer.getOpacity() !== 0) {
zoomToFeature(id, feature as Feature)
if (feature.get('entity_id')) {
setSelectedDistrict(id, feature.get('entity_id'))
map.removeInteraction(districtSelect)
}
}
}
@ -360,7 +399,9 @@ export const initializeMapState = (
}
})
regionsLayer.setVisible(!isViewCovered)
if (!getSelectedRegion(id)) {
regionsLayer.setOpacity(isViewCovered ? 0 : 1)
}
})
map.setView(
@ -386,7 +427,8 @@ export const initializeMapState = (
currentX: undefined,
currentY: undefined,
currentCoordinate: null,
statusText: '',
statusText: null,
statusTextPosition: [0, 0],
satMapsProvider: 'google',
selectedObjectType: null,
alignMode: false,
@ -411,8 +453,8 @@ export const initializeMapState = (
staticMapLayer: staticMapLayer,
figuresLayer: figuresLayer,
linesLayer: linesLayer,
districtsLayer: districtsLayer,
regionsLayer: regionsLayer,
citiesLayer: citiesLayer,
districtBoundLayer: districtBoundLayer,
imageLayer: imageLayer,
selectedArea: null,
@ -421,6 +463,7 @@ export const initializeMapState = (
measureModify: measureModify,
nodeLayer: nodeLayer,
overlayLayer: overlayLayer,
districtSelect: districtSelect,
regionSelect: regionSelect,
lineSelect: lineSelect,
previousView: null,
@ -644,7 +687,16 @@ export const setCurrentCoordinate = (id: string, c: Coordinate | null) => useMap
}
})
export const setStatusText = (id: string, t: string) => useMapStore.setState((state) => {
export const setStatusTextPosition = (id: string, left: number, top: number) => useMapStore.setState((state) => {
return {
id: {
...state.id,
[id]: { ...state.id[id], statusTextPosition: [left, top] }
}
}
})
export const setStatusText = (id: string, t: string | null) => useMapStore.setState((state) => {
return {
id: {
...state.id,

View File

@ -0,0 +1,36 @@
import { create } from 'zustand';
import { IDistrict, IRegion } from '../interfaces/gis';
export interface RegionsState {
regionsData: IRegion[],
districtsData: IDistrict[],
}
export const useRegionsStore = create<RegionsState>(() => ({
regionsData: [],
districtsData: []
}))
export const getRegionData = (id: number) => {
return useRegionsStore.getState().regionsData.find(region => region.id === id)
}
export const getRegionsData = () => {
return useRegionsStore.getState().regionsData
}
export const setRegionsData = (regionsData: any) => {
useRegionsStore.setState(() => ({ regionsData: regionsData }))
}
export const getDistrictData = (id: number) => {
return useRegionsStore.getState().districtsData.find(district => district.id === id)
}
export const getDistrictsData = () => {
return useRegionsStore.getState().districtsData
}
export const setDistrictsData = (districtsData: any) => {
useRegionsStore.setState(() => ({ districtsData: districtsData }))
}

File diff suppressed because it is too large Load Diff

View File

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

17
server/Dockerfile Normal file
View File

@ -0,0 +1,17 @@
FROM node:20 AS base
FROM base AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install --cpu=x64 --os=linux sharp
COPY . .
RUN npm run build
EXPOSE $API_PORT
CMD ["node", "dist/main"]

243
server/package-lock.json generated
View File

@ -23,7 +23,7 @@
"pg": "^8.16.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.2",
"sharp": "0.33.5",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.24"
},
@ -1007,9 +1007,9 @@
}
},
"node_modules/@emnapi/runtime": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.4.3.tgz",
"integrity": "sha512-pBPWdu6MLKROBX05wSNKcNb++m5Er+KQ9QkB+WVM+pW2Kx9hoSrVTnu3BdkI5eBLZoKu/J6mW/B6i6bJB2ytXQ==",
"version": "1.5.0",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.5.0.tgz",
"integrity": "sha512-97/BJ3iXHww3djw6hYIfErCZFee7qCtrneuLa20UXFCOTCfBM2cvQHjWJ2EG0s0MtdNwInarqCTz35i4wWXHsQ==",
"license": "MIT",
"optional": true,
"dependencies": {
@ -1244,9 +1244,9 @@
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.34.2.tgz",
"integrity": "sha512-OfXHZPppddivUJnqyKoi5YVeHRkkNE2zUFT2gbpKxp/JZCFYEYubnMg+gOp6lWfasPrTS+KPosKqdI+ELYVDtg==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.33.5.tgz",
"integrity": "sha512-UT4p+iz/2H4twwAoLCqfA9UH5pI6DggwKEGuaPy7nCVQ8ZsiY5PIcrRvD1DzuY3qYL07NtIQcWnBSY/heikIFQ==",
"cpu": [
"arm64"
],
@ -1262,13 +1262,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.1.0"
"@img/sharp-libvips-darwin-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.34.2.tgz",
"integrity": "sha512-dYvWqmjU9VxqXmjEtjmvHnGqF8GrVjM2Epj9rJ6BUIXvk8slvNDJbhGFvIoXzkDhrJC2jUxNLz/GUjjvSzfw+g==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.33.5.tgz",
"integrity": "sha512-fyHac4jIc1ANYGRDxtiqelIbdWkIuQaI84Mv45KvGRRxSAa7o7d1ZKAOBaYbnepLC1WqxfpimdeWfvqqSGwR2Q==",
"cpu": [
"x64"
],
@ -1284,13 +1284,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.1.0"
"@img/sharp-libvips-darwin-x64": "1.0.4"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.1.0.tgz",
"integrity": "sha512-HZ/JUmPwrJSoM4DIQPv/BfNh9yrOA8tlBbqbLz4JZ5uew2+o22Ik+tHQJcih7QJuSa0zo5coHTfD5J8inqj9DA==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.0.4.tgz",
"integrity": "sha512-XblONe153h0O2zuFfTAbQYAX2JhYmDHeWikp1LM9Hul9gVPjFY427k6dFEcOL72O01QxQsWi761svJ/ev9xEDg==",
"cpu": [
"arm64"
],
@ -1304,9 +1304,9 @@
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.1.0.tgz",
"integrity": "sha512-Xzc2ToEmHN+hfvsl9wja0RlnXEgpKNmftriQp6XzY/RaSfwD9th+MSh0WQKzUreLKKINb3afirxW7A0fz2YWuQ==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.0.4.tgz",
"integrity": "sha512-xnGR8YuZYfJGmWPvmlunFaWJsb9T/AO2ykoP3Fz/0X5XV2aoYBPkX6xqCQvUTKKiLddarLaxpzNe+b1hjeWHAQ==",
"cpu": [
"x64"
],
@ -1320,9 +1320,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.1.0.tgz",
"integrity": "sha512-s8BAd0lwUIvYCJyRdFqvsj+BJIpDBSxs6ivrOPm/R7piTs5UIwY5OjXrP2bqXC9/moGsyRa37eYWYCOGVXxVrA==",
"version": "1.0.5",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.0.5.tgz",
"integrity": "sha512-gvcC4ACAOPRNATg/ov8/MnbxFDJqf/pDePbBnuBDcjsI8PssmjoKMAz4LtLaVi+OnSb5FK/yIOamqDwGmXW32g==",
"cpu": [
"arm"
],
@ -1336,9 +1336,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.1.0.tgz",
"integrity": "sha512-IVfGJa7gjChDET1dK9SekxFFdflarnUB8PwW8aGwEoF3oAsSDuNUTYS+SKDOyOJxQyDC1aPFMuRYLoDInyV9Ew==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.0.4.tgz",
"integrity": "sha512-9B+taZ8DlyyqzZQnoeIvDVR/2F4EbMepXMc/NdVbkzsJbzkUjhXv/70GQJ7tdLA4YJgNP25zukcxpX2/SueNrA==",
"cpu": [
"arm64"
],
@ -1351,26 +1351,10 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.1.0.tgz",
"integrity": "sha512-tiXxFZFbhnkWE2LA8oQj7KYR+bWBkiV2nilRldT7bqoEZ4HiDOcePr9wVDAZPi/Id5fT1oY9iGnDq20cwUz8lQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.1.0.tgz",
"integrity": "sha512-xukSwvhguw7COyzvmjydRb3x/09+21HykyapcZchiCUkTThEQEOMtBj9UhkaBRLuBrgLFzQ2wbxdeCCJW/jgJA==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.0.4.tgz",
"integrity": "sha512-u7Wz6ntiSSgGSGcjZ55im6uvTrOxSIS8/dgoVMoiGE9I6JAfU50yH5BoDlYA1tcuGS7g/QNtetJnxA6QEsCVTA==",
"cpu": [
"s390x"
],
@ -1384,9 +1368,9 @@
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.1.0.tgz",
"integrity": "sha512-yRj2+reB8iMg9W5sULM3S74jVS7zqSzHG3Ol/twnAAkAhnGQnpjj6e4ayUz7V+FpKypwgs82xbRdYtchTTUB+Q==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.0.4.tgz",
"integrity": "sha512-MmWmQ3iPFZr0Iev+BAgVMb3ZyC4KeFc3jFxnNbEPas60e1cIfevbtuyf9nDGIzOaW9PdnDciJm+wFFaTlj5xYw==",
"cpu": [
"x64"
],
@ -1400,9 +1384,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.1.0.tgz",
"integrity": "sha512-jYZdG+whg0MDK+q2COKbYidaqW/WTz0cc1E+tMAusiDygrM4ypmSCjOJPmFTvHHJ8j/6cAGyeDWZOsK06tP33w==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.0.4.tgz",
"integrity": "sha512-9Ti+BbTYDcsbp4wfYib8Ctm1ilkugkA/uscUn6UXK1ldpC1JjiXbLfFZtRlBhjPZ5o1NCLiDbg8fhUPKStHoTA==",
"cpu": [
"arm64"
],
@ -1416,9 +1400,9 @@
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.1.0.tgz",
"integrity": "sha512-wK7SBdwrAiycjXdkPnGCPLjYb9lD4l6Ze2gSdAGVZrEL05AOUJESWU2lhlC+Ffn5/G+VKuSm6zzbQSzFX/P65A==",
"version": "1.0.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.0.4.tgz",
"integrity": "sha512-viYN1KX9m+/hGkJtvYYp+CCLgnJXwiQB39damAO7WMdKWlIhmYTfHjwSbQeUK/20vY154mwezd9HflVFM1wVSw==",
"cpu": [
"x64"
],
@ -1432,9 +1416,9 @@
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.34.2.tgz",
"integrity": "sha512-0DZzkvuEOqQUP9mo2kjjKNok5AmnOr1jB2XYjkaoNRwpAYMDzRmAqUIa1nRi58S2WswqSfPOWLNOr0FDT3H5RQ==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.33.5.tgz",
"integrity": "sha512-JTS1eldqZbJxjvKaAkxhZmBqPRGmxgu+qFKSInv8moZ2AmT5Yib3EQ1c6gp493HvrvV8QgdOXdyaIBrhvFhBMQ==",
"cpu": [
"arm"
],
@ -1450,13 +1434,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.1.0"
"@img/sharp-libvips-linux-arm": "1.0.5"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.34.2.tgz",
"integrity": "sha512-D8n8wgWmPDakc83LORcfJepdOSN6MvWNzzz2ux0MnIbOqdieRZwVYY32zxVx+IFUT8er5KPcyU3XXsn+GzG/0Q==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.33.5.tgz",
"integrity": "sha512-JMVv+AMRyGOHtO1RFBiJy/MBsgz0x4AWrT6QoEVVTyh1E39TrCUpTRI7mx9VksGX4awWASxqCYLCV4wBZHAYxA==",
"cpu": [
"arm64"
],
@ -1472,13 +1456,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.1.0"
"@img/sharp-libvips-linux-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.34.2.tgz",
"integrity": "sha512-EGZ1xwhBI7dNISwxjChqBGELCWMGDvmxZXKjQRuqMrakhO8QoMgqCrdjnAqJq/CScxfRn+Bb7suXBElKQpPDiw==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.33.5.tgz",
"integrity": "sha512-y/5PCd+mP4CA/sPDKl2961b+C9d+vPAveS33s6Z3zfASk2j5upL6fXVPZi7ztePZ5CuH+1kW8JtvxgbuXHRa4Q==",
"cpu": [
"s390x"
],
@ -1494,13 +1478,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.1.0"
"@img/sharp-libvips-linux-s390x": "1.0.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.34.2.tgz",
"integrity": "sha512-sD7J+h5nFLMMmOXYH4DD9UtSNBD05tWSSdWAcEyzqW8Cn5UxXvsHAxmxSesYUsTOBmUnjtxghKDl15EvfqLFbQ==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.33.5.tgz",
"integrity": "sha512-opC+Ok5pRNAzuvq1AG0ar+1owsu842/Ab+4qvU879ippJBHvyY5n2mxF1izXqkPYlGuP/M556uh53jRLJmzTWA==",
"cpu": [
"x64"
],
@ -1516,13 +1500,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.1.0"
"@img/sharp-libvips-linux-x64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.34.2.tgz",
"integrity": "sha512-NEE2vQ6wcxYav1/A22OOxoSOGiKnNmDzCYFOZ949xFmrWZOVII1Bp3NqVVpvj+3UeHMFyN5eP/V5hzViQ5CZNA==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.33.5.tgz",
"integrity": "sha512-XrHMZwGQGvJg2V/oRSUfSAfjfPxO+4DkiRh6p2AFjLQztWUuY/o8Mq0eMQVIY7HJ1CDQUJlxGGZRw1a5bqmd1g==",
"cpu": [
"arm64"
],
@ -1538,13 +1522,13 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0"
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.34.2.tgz",
"integrity": "sha512-DOYMrDm5E6/8bm/yQLCWyuDJwUnlevR8xtF8bs+gjZ7cyUNYXiSf/E8Kp0Ss5xasIaXSHzb888V1BE4i1hFhAA==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.33.5.tgz",
"integrity": "sha512-WT+d/cgqKkkKySYmqoZ8y3pxx7lx9vVejxW/W4DOFMYVSkErR+w7mf2u8m/y4+xHe7yY9DAXQMWQhpnMuFfScw==",
"cpu": [
"x64"
],
@ -1560,20 +1544,20 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.1.0"
"@img/sharp-libvips-linuxmusl-x64": "1.0.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.34.2.tgz",
"integrity": "sha512-/VI4mdlJ9zkaq53MbIG6rZY+QRN3MLbR6usYlgITEzi4Rpx5S6LFKsycOQjkOGmqTNmkIdLjEvooFKwww6OpdQ==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.33.5.tgz",
"integrity": "sha512-ykUW4LVGaMcU9lu9thv85CbRMAwfeadCJHRsg2GmeRa/cJxsVY9Rbd57JcMxBkKHag5U/x7TSBpScF4U8ElVzg==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.4.3"
"@emnapi/runtime": "^1.2.0"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@ -1582,29 +1566,10 @@
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.34.2.tgz",
"integrity": "sha512-cfP/r9FdS63VA5k0xiqaNaEoGxBg9k7uE+RQGzuK9fHt7jib4zAVVseR9LsE4gJcNWgT6APKMNnCcnyOtmSEUQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.34.2.tgz",
"integrity": "sha512-QLjGGvAbj0X/FXl8n1WbtQ6iVBpWU7JO94u/P2M4a8CFYsvQi4GW2mRy/JqkRx0qpBzaOdKJKw8uc930EX2AHw==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.33.5.tgz",
"integrity": "sha512-T36PblLaTwuVJ/zw/LaH0PdZkRz5rd3SmMHX8GSmR7vtNSP5Z6bQkExdSK7xGWyxLw4sUknBuugTelgw2faBbQ==",
"cpu": [
"ia32"
],
@ -1621,9 +1586,9 @@
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.34.2.tgz",
"integrity": "sha512-aUdT6zEYtDKCaxkofmmJDJYGCf0+pJg3eU9/oBuqvEeoB9dKI6ZLc/1iLJCTuJQDO4ptntAlkUmHgGjyuobZbw==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.33.5.tgz",
"integrity": "sha512-MpY/o8/8kj+EcnxwvrP4aTJSWw/aZ7JIGR4aBeZkZw5B7/Jn+tY9/VNwtcoGmdT7GfggGIU4kygOMSbYnOrAbg==",
"cpu": [
"x64"
],
@ -7135,9 +7100,9 @@
}
},
"node_modules/detect-libc": {
"version": "2.0.4",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.0.4.tgz",
"integrity": "sha512-3UDv+G9CsCKO1WKMGw9fwq/SWJYbI0c5Y7LU1AXYoDdbhE2AHQ6N6Nb34sG8Fj7T5APy8qXDCKuuIHd1BR0tVA==",
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.0.tgz",
"integrity": "sha512-vEtk+OcP7VBRtQZ1EJ3bdgzSfBjgnEalLTp5zjJrS+2Z1w2KZly4SBdac/WDU3hhsNAZ9E8SC96ME4Ey8MZ7cg==",
"license": "Apache-2.0",
"engines": {
"node": ">=8"
@ -12610,15 +12575,15 @@
}
},
"node_modules/sharp": {
"version": "0.34.2",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.34.2.tgz",
"integrity": "sha512-lszvBmB9QURERtyKT2bNmsgxXK0ShJrL/fvqlonCo7e6xBF8nT8xU6pW+PMIbLsz0RxQk3rgH9kd8UmvOzlMJg==",
"version": "0.33.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.33.5.tgz",
"integrity": "sha512-haPVm1EkS9pgvHrQ/F3Xy+hgcuMV0Wm9vfIBSiwZ05k+xgb0PkBQpGsAA/oWdDobNaZTH5ppvHtzCFbnSEwHVw==",
"hasInstallScript": true,
"license": "Apache-2.0",
"dependencies": {
"color": "^4.2.3",
"detect-libc": "^2.0.4",
"semver": "^7.7.2"
"detect-libc": "^2.0.3",
"semver": "^7.6.3"
},
"engines": {
"node": "^18.17.0 || ^20.3.0 || >=21.0.0"
@ -12627,27 +12592,25 @@
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.34.2",
"@img/sharp-darwin-x64": "0.34.2",
"@img/sharp-libvips-darwin-arm64": "1.1.0",
"@img/sharp-libvips-darwin-x64": "1.1.0",
"@img/sharp-libvips-linux-arm": "1.1.0",
"@img/sharp-libvips-linux-arm64": "1.1.0",
"@img/sharp-libvips-linux-ppc64": "1.1.0",
"@img/sharp-libvips-linux-s390x": "1.1.0",
"@img/sharp-libvips-linux-x64": "1.1.0",
"@img/sharp-libvips-linuxmusl-arm64": "1.1.0",
"@img/sharp-libvips-linuxmusl-x64": "1.1.0",
"@img/sharp-linux-arm": "0.34.2",
"@img/sharp-linux-arm64": "0.34.2",
"@img/sharp-linux-s390x": "0.34.2",
"@img/sharp-linux-x64": "0.34.2",
"@img/sharp-linuxmusl-arm64": "0.34.2",
"@img/sharp-linuxmusl-x64": "0.34.2",
"@img/sharp-wasm32": "0.34.2",
"@img/sharp-win32-arm64": "0.34.2",
"@img/sharp-win32-ia32": "0.34.2",
"@img/sharp-win32-x64": "0.34.2"
"@img/sharp-darwin-arm64": "0.33.5",
"@img/sharp-darwin-x64": "0.33.5",
"@img/sharp-libvips-darwin-arm64": "1.0.4",
"@img/sharp-libvips-darwin-x64": "1.0.4",
"@img/sharp-libvips-linux-arm": "1.0.5",
"@img/sharp-libvips-linux-arm64": "1.0.4",
"@img/sharp-libvips-linux-s390x": "1.0.4",
"@img/sharp-libvips-linux-x64": "1.0.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.0.4",
"@img/sharp-libvips-linuxmusl-x64": "1.0.4",
"@img/sharp-linux-arm": "0.33.5",
"@img/sharp-linux-arm64": "0.33.5",
"@img/sharp-linux-s390x": "0.33.5",
"@img/sharp-linux-x64": "0.33.5",
"@img/sharp-linuxmusl-arm64": "0.33.5",
"@img/sharp-linuxmusl-x64": "0.33.5",
"@img/sharp-wasm32": "0.33.5",
"@img/sharp-win32-ia32": "0.33.5",
"@img/sharp-win32-x64": "0.33.5"
}
},
"node_modules/shebang-command": {
@ -12801,18 +12764,18 @@
}
},
"node_modules/simple-swizzle": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
"integrity": "sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==",
"version": "0.2.4",
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.4.tgz",
"integrity": "sha512-nAu1WFPQSMNr2Zn9PGSZK9AGn4t/y97lEm+MXTtUDwfP0ksAIX4nO+6ruD9Jwut4C49SB1Ws+fbXsm/yScWOHw==",
"license": "MIT",
"dependencies": {
"is-arrayish": "^0.3.1"
}
},
"node_modules/simple-swizzle/node_modules/is-arrayish": {
"version": "0.3.2",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.2.tgz",
"integrity": "sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==",
"version": "0.3.4",
"resolved": "https://registry.npmjs.org/is-arrayish/-/is-arrayish-0.3.4.tgz",
"integrity": "sha512-m6UrgzFVUYawGBh1dUsWR5M2Clqic9RVXC/9f8ceNlv2IcO9j9J/z8UoCLPqtsPBFNzEpfR3xftohbfqDx8EQA==",
"license": "MIT"
},
"node_modules/sisteransi": {

View File

@ -34,7 +34,7 @@
"pg": "^8.16.0",
"reflect-metadata": "^0.2.2",
"rxjs": "^7.8.1",
"sharp": "^0.34.2",
"sharp": "0.33.5",
"sqlite3": "^5.1.7",
"typeorm": "^0.3.24"
},

View File

@ -1,7 +1,10 @@
import { Entity, PrimaryGeneratedColumn } from "typeorm"
import { ApiProperty } from '@nestjs/swagger';
// @Entity()
// export class Bound {
// @PrimaryGeneratedColumn()
// id:
// }
export class BoundsRequestDto {
@ApiProperty({
description: 'Array of entity IDs to fetch bounds for',
example: [11, 12, 13],
type: [Number],
})
list: number[];
}

View File

@ -1,5 +1,7 @@
import { Controller, Get, Param, ParseIntPipe, Query } from '@nestjs/common';
import { Body, Controller, Get, Param, ParseIntPipe, Post, Query } from '@nestjs/common';
import { GisService } from './gis.service';
import { ApiBody } from '@nestjs/swagger';
import { BoundsRequestDto } from './dto/bound';
@Controller('gis')
export class GisController {
@ -20,6 +22,15 @@ export class GisController {
return await this.gisService.getBoundsByEntityTypeAndId(entity_type, entity_id)
}
@Post('/bounds/:entity_type')
@ApiBody({ type: BoundsRequestDto })
async getBoundsByEntityTypeAndList(
@Param('entity_type') entity_type: 'region' | 'district' | 'city',
@Body('list') list: number[],
) {
return await this.gisService.getBoundsByEntityTypeAndList(entity_type, list);
}
@Get('/images/all')
async getImages(@Query('offset') offset: number, @Query('limit') limit: number, @Query('city_id') city_id: number) {
return await this.gisService.getImages(offset, limit, city_id)

View File

@ -64,6 +64,50 @@ export class GisService {
}
}
async getBoundsByEntityTypeAndList(
entity_type: 'region' | 'district' | 'city',
list: number[],
): Promise<any[]> {
if (!list || list.length === 0) {
throw new NotFoundException('No entity IDs provided');
}
// Build placeholders (?, ?, ?) for SQLite IN clause
const placeholders = list.map(() => '?').join(', ');
const result = await this.dataSource.query(
`
SELECT entity_id, entity_type, geometry
FROM bounds
WHERE entity_type = ?
AND entity_id IN (${placeholders})
`,
[entity_type, ...list],
);
if (!Array.isArray(result) || result.length === 0) {
throw new NotFoundException('Not found');
}
return result.map(
(bound: {
id: string;
entity_id: number;
entity_type: string;
geometry: string;
published_at: string;
deleted_at: string | null;
}) => ({
...(JSON.parse(bound.geometry)),
properties: {
id: bound.id,
entity_id: bound.entity_id,
entity_type: bound.entity_type,
},
}),
);
}
async getImages(offset: number, limit: number, city_id: number): Promise<any[]> {
const result = await this.emsDataSource.query(`
SELECT * FROM images

View File

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

View File

@ -126,7 +126,7 @@ export async function generateTilesForZoomLevel(uploadDir: string, tileFolder: s
Logger.log('sharping step 2')
if (initialZoomImage) {
await sharp(initialZoomImage.data.buffer)
await sharp(initialZoomImage.data)
.resize({
width: roundUpToNearest(Math.ceil(boundsWidthPixel) + Math.ceil(paddingLeftPixel) + Math.ceil(paddingRightPixel), Math.abs(minX - (maxX + 1))),
height: roundUpToNearest(Math.ceil(boundsHeightPixel) + Math.ceil(paddingTopPixel) + Math.ceil(paddingBottomPixel), Math.abs(minY - (maxY + 1))),