This commit is contained in:
dv 2026-04-08 21:37:04 +03:00
parent 2983e4e5e7
commit 9bd22bf3d9
8 changed files with 651 additions and 7 deletions

View file

@ -7,7 +7,13 @@
"Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")", "Bash(powershell -Command \"Get-Content ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' -Raw | Write-Output\")",
"Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")", "Bash(powershell -Command \"[System.IO.File]::ReadAllText\\(''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv''\\)\")",
"Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")", "Bash(powershell -Command \"Get-Item ''C:\\\\Users\\\\meme\\\\Downloads\\\\racks.csv'' | Select-Object Length, LastWriteTime\")",
"Bash(npx tsc:*)" "Bash(npx tsc:*)",
"Bash(python -c \"import openpyxl; print\\(''openpyxl available:'', openpyxl.__version__\\)\")",
"Bash(pip install:*)",
"Bash(python create_objects_template.py)",
"Bash(python -c \":*)",
"Bash(python -c \"import sys; data = sys.stdin.buffer.read\\(\\); print\\(data.decode\\(''''utf-8'''', errors=''''replace''''\\)\\)\")",
"Bash(docker compose:*)"
] ]
} }
} }

View file

@ -1,6 +1,8 @@
import asyncio import asyncio
import re
from typing import Annotated
from fastapi import APIRouter, Depends, HTTPException, status from fastapi import APIRouter, Depends, File, HTTPException, UploadFile, status
from sqlalchemy import select, distinct from sqlalchemy import select, distinct
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm import selectinload from sqlalchemy.orm import selectinload
@ -11,7 +13,19 @@ from app.models.object import Object
from app.models.tag import Tag from app.models.tag import Tag
from app.models.user import User from app.models.user import User
from app.schemas.admin import SheetsSyncRequest from app.schemas.admin import SheetsSyncRequest
from app.schemas.object import ObjectCreate, ObjectMetaResponse, ObjectRead, ObjectUpdate, SyncResult, DiscoveryStarted from app.schemas.object import (
ObjectCreate,
ObjectImportError,
ObjectImportPreview,
ObjectImportResult,
ObjectImportRow,
ObjectImportSkipped,
ObjectMetaResponse,
ObjectRead,
ObjectUpdate,
SyncResult,
DiscoveryStarted,
)
from app.services import crypto from app.services import crypto
from app.services.database import AsyncSessionLocal from app.services.database import AsyncSessionLocal
from app.services.device_discovery import discover_via_ssh from app.services.device_discovery import discover_via_ssh
@ -215,6 +229,182 @@ async def discover_devices(
return DiscoveryStarted(task_id=task_id) return DiscoveryStarted(task_id=task_id)
@router.post("/import/preview", response_model=ObjectImportPreview)
async def preview_objects_import(
file: Annotated[UploadFile, File()],
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Dry-run: parse xlsx and return what would be created/skipped."""
return await _parse_objects_xlsx(db, file, dry_run=True)
@router.post("/import", response_model=ObjectImportResult, status_code=status.HTTP_201_CREATED)
async def import_objects(
file: Annotated[UploadFile, File()],
db: AsyncSession = Depends(get_db),
_: User = Depends(get_current_user),
):
"""Import objects from xlsx. Deduplication by server_ip (skip + warn on duplicate)."""
preview = await _parse_objects_xlsx(db, file, dry_run=False)
return ObjectImportResult(
created=len(preview.would_create),
skipped=len(preview.would_skip),
errors=preview.errors,
)
def _norm_col(name: str) -> str:
"""Normalize xlsx header: 'server_ip *''server_ip', 'ssh_user (root)''ssh_user'."""
name = re.sub(r"\s*\*", "", name)
name = re.sub(r"\s*\(.*?\)", "", name)
return name.strip().lower()
async def _parse_objects_xlsx(
db: AsyncSession, file: UploadFile, dry_run: bool
) -> ObjectImportPreview:
import io
try:
from openpyxl import load_workbook
except ImportError:
raise HTTPException(status_code=500, detail="openpyxl not installed")
content = await file.read()
try:
wb = load_workbook(io.BytesIO(content), read_only=True, data_only=True)
except Exception:
raise HTTPException(status_code=400, detail="Не удалось открыть файл. Убедитесь, что файл в формате .xlsx")
# Use first sheet (the data sheet)
ws = wb.worksheets[0]
rows = list(ws.iter_rows(values_only=True))
if not rows:
raise HTTPException(status_code=400, detail="Файл пустой")
# Build column index from header row
header = [_norm_col(str(c)) if c is not None else "" for c in rows[0]]
col = {name: idx for idx, name in enumerate(header) if name}
required = {"name", "city", "server_ip"}
missing = required - col.keys()
if missing:
raise HTTPException(
status_code=400,
detail=f"Отсутствуют обязательные колонки: {', '.join(sorted(missing))}",
)
def _cell(row: tuple, key: str) -> str | None:
idx = col.get(key)
if idx is None:
return None
val = row[idx]
if val is None:
return None
s = str(val).strip()
return s if s else None
would_create: list[ObjectImportRow] = []
would_skip: list[ObjectImportSkipped] = []
errors: list[ObjectImportError] = []
# Track IPs seen within this file to catch intra-file duplicates
seen_ips: dict[str, int] = {}
# Pre-load existing server_ips from DB for fast lookup
existing_ips_result = await db.execute(
select(Object.server_ip, Object.name).where(Object.server_ip.isnot(None))
)
existing_ips: dict[str, str] = {ip: name for ip, name in existing_ips_result.all()}
for row_num, row in enumerate(rows[1:], start=2):
# Skip entirely blank rows
if all(c is None or str(c).strip() == "" for c in row):
continue
name = _cell(row, "name")
city = _cell(row, "city")
server_ip = _cell(row, "server_ip")
# Validate required fields
missing_fields = []
if not name:
missing_fields.append("name")
if not city:
missing_fields.append("city")
if not server_ip:
missing_fields.append("server_ip")
if missing_fields:
errors.append(ObjectImportError(
row=row_num,
reason=f"Обязательные поля не заполнены: {', '.join(missing_fields)}",
))
continue
# Deduplicate within file
if server_ip in seen_ips:
errors.append(ObjectImportError(
row=row_num,
reason=f"Дублирующийся IP {server_ip} в файле (строка {seen_ips[server_ip]})",
))
continue
seen_ips[server_ip] = row_num
# Deduplicate against DB
if server_ip in existing_ips:
would_skip.append(ObjectImportSkipped(
row=row_num,
name=name,
server_ip=server_ip,
reason=f"Объект с IP {server_ip} уже существует: «{existing_ips[server_ip]}»",
))
continue
ssh_user = _cell(row, "ssh_user") or "root"
ssh_password = _cell(row, "ssh_password") or "123123"
db_via_tunnel_raw = _cell(row, "db_via_tunnel")
db_via_tunnel = bool(int(db_via_tunnel_raw)) if db_via_tunnel_raw and db_via_tunnel_raw.isdigit() else False
import_row = ObjectImportRow(
row=row_num,
name=name,
city=city,
server_ip=server_ip,
client=_cell(row, "client"),
description=_cell(row, "description"),
notes=_cell(row, "notes"),
ssh_user=ssh_user,
db_via_tunnel=db_via_tunnel,
)
would_create.append(import_row)
if not dry_run:
obj = Object(
name=name,
city=city,
server_ip=server_ip,
client=import_row.client,
description=import_row.description,
notes=import_row.notes,
ssh_user=ssh_user,
db_via_tunnel=db_via_tunnel,
is_active=True,
)
obj.ssh_pass_enc = crypto.encrypt(ssh_password)
db.add(obj)
# update local cache so next rows in this file see the newly added IP
existing_ips[server_ip] = name
if not dry_run:
await db.flush()
return ObjectImportPreview(
would_create=would_create,
would_skip=would_skip,
errors=errors,
)
@router.post("/{obj_id}/sync/sheets", response_model=SyncResult) @router.post("/{obj_id}/sync/sheets", response_model=SyncResult)
async def sync_devices_from_sheets( async def sync_devices_from_sheets(
obj_id: int, obj_id: int,

View file

@ -85,6 +85,44 @@ class ObjectMetaResponse(BaseModel):
clients: list[str] clients: list[str]
# --- Import from xlsx ---
class ObjectImportRow(BaseModel):
row: int
name: str
city: str
server_ip: str
client: Optional[str] = None
description: Optional[str] = None
notes: Optional[str] = None
ssh_user: str
db_via_tunnel: bool
class ObjectImportSkipped(BaseModel):
row: int
name: str
server_ip: str
reason: str
class ObjectImportError(BaseModel):
row: int
reason: str
class ObjectImportPreview(BaseModel):
would_create: list[ObjectImportRow]
would_skip: list[ObjectImportSkipped]
errors: list[ObjectImportError]
class ObjectImportResult(BaseModel):
created: int
skipped: int
errors: list[ObjectImportError]
class SyncResult(BaseModel): class SyncResult(BaseModel):
created: int created: int
updated: int updated: int

View file

@ -21,6 +21,7 @@ dependencies = [
# Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var) # Google Sheets sync (optional — requires GOOGLE_SERVICE_ACCOUNT_JSON env var)
"gspread>=6.0", "gspread>=6.0",
"google-auth>=2.0", "google-auth>=2.0",
"openpyxl>=3.1",
] ]
[project.optional-dependencies] [project.optional-dependencies]

129
create_objects_template.py Normal file
View file

@ -0,0 +1,129 @@
from openpyxl import Workbook
from openpyxl.styles import Font, PatternFill, Alignment, Border, Side
from openpyxl.utils import get_column_letter
wb = Workbook()
ws = wb.active
ws.title = "Импорт объектов"
DARK_BLUE = "1F3864"
LIGHT_GRAY = "F2F2F2"
LIGHT_BLUE = "EBF3FB"
WHITE = "FFFFFF"
columns = [
("name *", 30),
("city *", 20),
("server_ip *", 18),
("client", 25),
("description", 35),
("notes", 35),
("ssh_user (root)", 15),
("ssh_password (123123)", 18),
("db_via_tunnel (0)", 20),
]
sample_row = [
"Парковка ТЦ Мега",
"Москва",
"192.168.1.10",
"ООО Рога и Копыта",
"Въезд с ул. Ленина",
"Контакт: +7 999 000-00-00",
"admin",
"qwerty123",
0,
]
header_font = Font(name="Arial", bold=True, color=WHITE, size=11)
header_fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE)
header_align = Alignment(horizontal="center", vertical="center", wrap_text=True)
sample_fill = PatternFill("solid", start_color=LIGHT_GRAY, fgColor=LIGHT_GRAY)
blue_fill = PatternFill("solid", start_color=LIGHT_BLUE, fgColor=LIGHT_BLUE)
white_fill = PatternFill("solid", start_color=WHITE, fgColor=WHITE)
data_font = Font(name="Arial", size=10)
data_align = Alignment(vertical="center")
# Header row
for col_idx, (header, width) in enumerate(columns, start=1):
cell = ws.cell(row=1, column=col_idx, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_align
ws.column_dimensions[get_column_letter(col_idx)].width = width
ws.row_dimensions[1].height = 30
# Sample data row (row 2)
for col_idx, value in enumerate(sample_row, start=1):
cell = ws.cell(row=2, column=col_idx, value=value)
cell.font = data_font
cell.fill = sample_fill
cell.alignment = data_align
# Empty data rows 3-52, alternating white / light blue
for row in range(3, 53):
fill = white_fill if (row % 2 == 1) else blue_fill
for col_idx in range(1, len(columns) + 1):
cell = ws.cell(row=row, column=col_idx)
cell.font = data_font
cell.fill = fill
cell.alignment = data_align
ws.freeze_panes = "A2"
# --- Справка sheet ---
ws2 = wb.create_sheet("Справка")
ws2.column_dimensions["A"].width = 22
ws2.column_dimensions["B"].width = 40
ws2.column_dimensions["C"].width = 20
ws2.column_dimensions["D"].width = 20
title_cell = ws2.cell(row=1, column=1, value="Инструкция по заполнению")
title_cell.font = Font(name="Arial", bold=True, size=13)
ws2.row_dimensions[1].height = 24
# Sub-header
headers_ref = ["Колонка", "Описание", "Обязательность", "Значение по умолчанию"]
for col_idx, h in enumerate(headers_ref, start=1):
cell = ws2.cell(row=3, column=col_idx, value=h)
cell.font = Font(name="Arial", bold=True, color=WHITE, size=11)
cell.fill = PatternFill("solid", start_color=DARK_BLUE, fgColor=DARK_BLUE)
cell.alignment = Alignment(horizontal="center", vertical="center")
ws2.row_dimensions[3].height = 22
ref_rows = [
("name", "Название объекта (парковки, площадки)", "Обязательно", ""),
("city", "Город расположения объекта", "Обязательно", ""),
("server_ip", "IP-адрес основного сервера CAPS", "Обязательно", ""),
("client", "Клиент / заказчик", "Необязательно",""),
("description", "Описание объекта", "Необязательно",""),
("notes", "Заметки (контакты, особенности и т.п.)", "Необязательно",""),
("ssh_user", "Логин для SSH-подключения к серверу объекта", "Необязательно","root"),
("ssh_password", "Пароль для SSH-подключения к серверу объекта", "Необязательно","123123"),
("db_via_tunnel", "Подключаться к БД CAPS через SSH-туннель (0 — нет, 1 — да)","Необязательно","0"),
]
for i, (col_name, desc, required, default) in enumerate(ref_rows, start=4):
fill = white_fill if (i % 2 == 0) else blue_fill
values = [col_name, desc, required, default]
for col_idx, val in enumerate(values, start=1):
cell = ws2.cell(row=i, column=col_idx, value=val)
cell.font = Font(name="Arial", size=10)
cell.fill = fill
cell.alignment = Alignment(vertical="center", wrap_text=True)
ws2.row_dimensions[i].height = 18
note_row = len(ref_rows) + 5
note_cell = ws2.cell(row=note_row, column=1,
value="⚠ Дедупликация по полю server_ip: если объект с таким IP уже существует в системе, строка будет пропущена с предупреждением.")
note_cell.font = Font(name="Arial", size=10, bold=True, color="C00000")
note_cell.alignment = Alignment(wrap_text=True)
ws2.merge_cells(start_row=note_row, start_column=1, end_row=note_row, end_column=4)
ws2.row_dimensions[note_row].height = 32
out_path = r"C:\Users\Professional\Documents\VSCode\utp_service\objects_import_template.xlsx"
wb.save(out_path)
print(f"Saved: {out_path}")

View file

@ -261,6 +261,69 @@ export const useObjectsMeta = () =>
staleTime: 30_000, staleTime: 30_000,
}) })
// --- Object xlsx import ---
export interface ObjectImportRow {
row: number
name: string
city: string
server_ip: string
client: string | null
description: string | null
notes: string | null
ssh_user: string
db_via_tunnel: boolean
}
export interface ObjectImportSkipped {
row: number
name: string
server_ip: string
reason: string
}
export interface ObjectImportError {
row: number
reason: string
}
export interface ObjectImportPreview {
would_create: ObjectImportRow[]
would_skip: ObjectImportSkipped[]
errors: ObjectImportError[]
}
export interface ObjectImportResult {
created: number
skipped: number
errors: ObjectImportError[]
}
export const usePreviewObjectsXLSX = () =>
useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api
.post<ObjectImportPreview>('/api/v1/objects/import/preview', form)
.then((r) => r.data)
},
})
export const useImportObjectsXLSX = () => {
const qc = useQueryClient()
return useMutation({
mutationFn: (file: File) => {
const form = new FormData()
form.append('file', file)
return api
.post<ObjectImportResult>('/api/v1/objects/import', form)
.then((r) => r.data)
},
onSuccess: () => qc.invalidateQueries({ queryKey: ['objects'] }),
})
}
export const useSyncSheets = (objId: number) => { export const useSyncSheets = (objId: number) => {
const qc = useQueryClient() const qc = useQueryClient()
return useMutation({ return useMutation({

View file

@ -1,5 +1,13 @@
import { DeleteOutlined, EditOutlined, PlusOutlined, RightOutlined } from '@ant-design/icons'
import { import {
DeleteOutlined,
EditOutlined,
PlusOutlined,
RightOutlined,
UploadOutlined,
WarningOutlined,
} from '@ant-design/icons'
import {
Alert,
AutoComplete, AutoComplete,
Button, Button,
Checkbox, Checkbox,
@ -13,8 +21,10 @@ import {
Popconfirm, Popconfirm,
Row, Row,
Space, Space,
Table,
Tag, Tag,
Typography, Typography,
Upload,
message, message,
} from 'antd' } from 'antd'
import { useMemo, useState } from 'react' import { useMemo, useState } from 'react'
@ -25,7 +35,10 @@ import {
useObjectsMeta, useObjectsMeta,
useUpdateObject, useUpdateObject,
useObjects, useObjects,
usePreviewObjectsXLSX,
useImportObjectsXLSX,
type ObjectItem, type ObjectItem,
type ObjectImportPreview,
} from '../api/objects' } from '../api/objects'
import { stripEmpty } from '../utils/form' import { stripEmpty } from '../utils/form'
@ -48,6 +61,15 @@ export function ObjectsPage({ mode }: Props) {
const [searchText, setSearchText] = useState('') const [searchText, setSearchText] = useState('')
const [form] = Form.useForm() const [form] = Form.useForm()
// Import modal state
const [importOpen, setImportOpen] = useState(false)
const [importFile, setImportFile] = useState<File | null>(null)
const [importPreview, setImportPreview] = useState<ObjectImportPreview | null>(null)
const [importStep, setImportStep] = useState<'upload' | 'preview'>('upload')
const previewMutation = usePreviewObjectsXLSX()
const importMutation = useImportObjectsXLSX()
const fileInputRef = useRef<HTMLInputElement>(null)
const objectUrl = (id: number) => const objectUrl = (id: number) =>
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}` mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
@ -133,6 +155,39 @@ export function ObjectsPage({ mode }: Props) {
const isSubmitting = createObject.isPending || updateObject.isPending const isSubmitting = createObject.isPending || updateObject.isPending
const openImport = () => {
setImportFile(null)
setImportPreview(null)
setImportStep('upload')
setImportOpen(true)
}
const handleImportFileChange = async (file: File) => {
setImportFile(file)
setImportPreview(null)
try {
const preview = await previewMutation.mutateAsync(file)
setImportPreview(preview)
setImportStep('preview')
} catch {
message.error('Не удалось прочитать файл')
}
}
const handleImportConfirm = async () => {
if (!importFile) return
try {
const result = await importMutation.mutateAsync(importFile)
setImportOpen(false)
message.success(
`Импорт завершён: создано ${result.created}, пропущено ${result.skipped}` +
(result.errors.length ? `, ошибок ${result.errors.length}` : '')
)
} catch {
message.error('Ошибка при импорте')
}
}
const renderObjectRow = (obj: ObjectItem) => ( const renderObjectRow = (obj: ObjectItem) => (
<div <div
key={obj.id} key={obj.id}
@ -212,9 +267,14 @@ export function ObjectsPage({ mode }: Props) {
onChange={(e) => setSearchText(e.target.value)} onChange={(e) => setSearchText(e.target.value)}
/> />
{mode === 'inventory' && ( {mode === 'inventory' && (
<Space>
<Button icon={<UploadOutlined />} onClick={openImport}>
Импортировать xlsx
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}> <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить Добавить
</Button> </Button>
</Space>
)} )}
</Space> </Space>
</div> </div>
@ -230,6 +290,163 @@ export function ObjectsPage({ mode }: Props) {
/> />
)} )}
{/* Import xlsx modal */}
<Modal
title="Импорт объектов из xlsx"
open={importOpen}
onCancel={() => setImportOpen(false)}
width={900}
footer={
importStep === 'preview' && importPreview
? [
<Button key="back" onClick={() => setImportStep('upload')}>
Назад
</Button>,
<Button
key="confirm"
type="primary"
loading={importMutation.isPending}
disabled={importPreview.would_create.length === 0}
onClick={handleImportConfirm}
>
Создать {importPreview.would_create.length} объектов
</Button>,
]
: null
}
>
{importStep === 'upload' && (
<div style={{ textAlign: 'center', padding: '32px 0' }}>
<Upload.Dragger
accept=".xlsx"
showUploadList={false}
beforeUpload={(file) => {
handleImportFileChange(file)
return false
}}
style={{ padding: '16px 32px' }}
>
<p className="ant-upload-drag-icon">
<UploadOutlined style={{ fontSize: 48, color: '#1677ff' }} />
</p>
<p className="ant-upload-text">Перетащите .xlsx файл или нажмите для выбора</p>
<p className="ant-upload-hint" style={{ color: '#999' }}>
Поддерживается шаблон objects_import_template.xlsx
</p>
</Upload.Dragger>
{previewMutation.isPending && (
<Typography.Text type="secondary" style={{ marginTop: 12, display: 'block' }}>
Анализируем файл...
</Typography.Text>
)}
</div>
)}
{importStep === 'preview' && importPreview && (
<div>
{/* Summary */}
<Space style={{ marginBottom: 16, flexWrap: 'wrap' }}>
<Tag color="green" style={{ fontSize: 13, padding: '2px 10px' }}>
Будет создано: {importPreview.would_create.length}
</Tag>
{importPreview.would_skip.length > 0 && (
<Tag color="orange" style={{ fontSize: 13, padding: '2px 10px' }}>
Пропущено (дубли): {importPreview.would_skip.length}
</Tag>
)}
{importPreview.errors.length > 0 && (
<Tag color="red" style={{ fontSize: 13, padding: '2px 10px' }}>
Ошибок: {importPreview.errors.length}
</Tag>
)}
</Space>
{/* Would create table */}
{importPreview.would_create.length > 0 && (
<>
<Typography.Text strong>Будет создано</Typography.Text>
<Table
size="small"
style={{ marginTop: 8, marginBottom: 16 }}
dataSource={importPreview.would_create}
rowKey="row"
pagination={{ pageSize: 10, hideOnSinglePage: true }}
columns={[
{ title: '#', dataIndex: 'row', width: 50 },
{ title: 'Название', dataIndex: 'name' },
{ title: 'Город', dataIndex: 'city', width: 130 },
{
title: 'IP сервера',
dataIndex: 'server_ip',
width: 140,
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{ title: 'Клиент', dataIndex: 'client', width: 130, render: (v) => v ?? '—' },
{
title: 'SSH',
dataIndex: 'ssh_user',
width: 80,
render: (v: string) => <Typography.Text type="secondary">{v}</Typography.Text>,
},
]}
/>
</>
)}
{/* Skipped */}
{importPreview.would_skip.length > 0 && (
<>
<Typography.Text strong>
<WarningOutlined style={{ color: '#fa8c16', marginRight: 6 }} />
Пропущены (дубликаты по IP)
</Typography.Text>
<Table
size="small"
style={{ marginTop: 8, marginBottom: 16 }}
dataSource={importPreview.would_skip}
rowKey="row"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
columns={[
{ title: '#', dataIndex: 'row', width: 50 },
{ title: 'Название', dataIndex: 'name' },
{
title: 'IP',
dataIndex: 'server_ip',
width: 140,
render: (v: string) => <Typography.Text code>{v}</Typography.Text>,
},
{ title: 'Причина', dataIndex: 'reason' },
]}
/>
</>
)}
{/* Errors */}
{importPreview.errors.length > 0 && (
<>
<Alert
type="error"
showIcon
style={{ marginBottom: 8 }}
message={`${importPreview.errors.length} строк с ошибками будут пропущены`}
/>
<Table
size="small"
style={{ marginBottom: 8 }}
dataSource={importPreview.errors}
rowKey="row"
pagination={{ pageSize: 5, hideOnSinglePage: true }}
columns={[
{ title: '#', dataIndex: 'row', width: 50 },
{ title: 'Ошибка', dataIndex: 'reason' },
]}
/>
</>
)}
</div>
)}
</Modal>
{/* Create / Edit modal — only relevant in inventory mode */} {/* Create / Edit modal — only relevant in inventory mode */}
<Modal <Modal
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`} title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}

Binary file not shown.