utp_service/frontend/src/pages/Objects.tsx
2026-04-09 12:26:50 +03:00

605 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import {
ControlOutlined,
DatabaseOutlined,
DeleteOutlined,
EditOutlined,
PlusOutlined,
RightOutlined,
UploadOutlined,
WarningOutlined,
} from '@ant-design/icons'
import {
Alert,
AutoComplete,
Button,
Checkbox,
Col,
Collapse,
Segmented,
Divider,
Form,
Input,
InputNumber,
Modal,
Popconfirm,
Row,
Space,
Table,
Tag,
Typography,
Upload,
message,
} from 'antd'
import dayjs from 'dayjs'
import { useMemo, useState } from 'react'
import { useNavigate } from 'react-router-dom'
import {
useCreateObject,
useDeleteObject,
useObjectsMeta,
useUpdateObject,
useObjects,
usePreviewObjectsXLSX,
useImportObjectsXLSX,
type ObjectItem,
type ObjectImportPreview,
} from '../api/objects'
import { stripEmpty } from '../utils/form'
interface Props {
mode: 'inventory' | 'work'
}
const UNGROUPED = '__none__'
export function ObjectsPage({ mode }: Props) {
const navigate = useNavigate()
const { data: objects, isLoading } = useObjects()
const { data: meta } = useObjectsMeta()
const createObject = useCreateObject()
const updateObject = useUpdateObject()
const deleteObject = useDeleteObject()
const [modalOpen, setModalOpen] = useState(false)
const [editTarget, setEditTarget] = useState<ObjectItem | null>(null)
const [searchText, setSearchText] = useState('')
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 objectUrl = (id: number) =>
mode === 'inventory' ? `/inventory/objects/${id}` : `/work/objects/${id}`
const filteredObjects = useMemo(() => {
if (!searchText) return objects ?? []
const q = searchText.toLowerCase()
return (objects ?? []).filter(
(o) =>
o.name.toLowerCase().includes(q) ||
(o.city ?? '').toLowerCase().includes(q) ||
(o.client ?? '').toLowerCase().includes(q)
)
}, [objects, searchText])
// Group: city → objects[]. Null city → UNGROUPED key
const cityGroups = useMemo(() => {
const map = new Map<string, ObjectItem[]>()
for (const obj of filteredObjects) {
const key = obj.city ?? UNGROUPED
if (!map.has(key)) map.set(key, [])
map.get(key)!.push(obj)
}
// Sort: named cities first (alphabetical), ungrouped last
const sorted = [...map.entries()].sort(([a], [b]) => {
if (a === UNGROUPED) return 1
if (b === UNGROUPED) return -1
return a.localeCompare(b, 'ru')
})
return sorted
}, [filteredObjects])
const openCreate = () => {
form.resetFields()
setEditTarget(null)
setModalOpen(true)
}
const openEdit = (row: ObjectItem) => {
form.resetFields()
form.setFieldsValue({
name: row.name,
description: row.description ?? '',
server_ip: row.server_ip ?? '',
city: row.city ?? '',
client: row.client ?? '',
ssh_user: row.ssh_user ?? '',
db_host: row.db_host ?? '',
})
setEditTarget(row)
setModalOpen(true)
}
const handleSubmit = async () => {
const values = await form.validateFields()
const payload = stripEmpty(values)
try {
if (editTarget === null) {
await createObject.mutateAsync(payload as any)
message.success('Объект создан')
} else {
await updateObject.mutateAsync({ id: editTarget.id, body: payload as any })
message.success('Объект обновлён')
}
setModalOpen(false)
form.resetFields()
} catch {
message.error(editTarget === null ? 'Ошибка при создании' : 'Ошибка при обновлении')
}
}
const handleDelete = async (row: ObjectItem) => {
try {
await deleteObject.mutateAsync(row.id)
message.success(`Объект "${row.name}" удалён`)
} catch (err: any) {
if (err?.response?.status === 409) {
message.error('Нельзя удалить объект с устройствами')
} else {
message.error(err?.response?.data?.detail ?? 'Ошибка при удалении')
}
}
}
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) => (
<div
key={obj.id}
style={{
display: 'flex',
alignItems: 'center',
padding: '8px 16px',
borderBottom: '1px solid #f0f0f0',
gap: 12,
}}
>
<Button
type="link"
style={{ padding: 0, fontWeight: 500, flex: 1, textAlign: 'left', justifyContent: 'flex-start' }}
onClick={() => navigate(objectUrl(obj.id))}
>
<RightOutlined style={{ fontSize: 11, marginRight: 6, color: '#999' }} />
{obj.name}
</Button>
{obj.client && <Typography.Text type="secondary" style={{ fontSize: 12 }}>{obj.client}</Typography.Text>}
{obj.server_ip && <Typography.Text code style={{ fontSize: 12 }}>{obj.server_ip}</Typography.Text>}
{obj.tags.map((t) => (
<Tag key={t.id} color={t.color} style={{ margin: 0 }}>{t.name}</Tag>
))}
{!obj.is_active && <Tag color="default">Неактивен</Tag>}
<Typography.Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
{obj.device_count} уст.
</Typography.Text>
<Typography.Text type="secondary" style={{ fontSize: 12, whiteSpace: 'nowrap' }}>
изм. {dayjs(obj.updated_at).format('DD.MM.YY HH:mm')}
</Typography.Text>
{mode === 'inventory' && (
<Space size={4}>
<Button size="small" icon={<EditOutlined />} onClick={() => openEdit(obj)} />
<Popconfirm
title="Удалить объект?"
description="Объект можно удалить только если у него нет устройств."
okText="Удалить"
cancelText="Отмена"
okButtonProps={{ danger: true }}
onConfirm={() => handleDelete(obj)}
>
<Button size="small" danger icon={<DeleteOutlined />} loading={deleteObject.isPending} />
</Popconfirm>
</Space>
)}
</div>
)
const collapseItems = cityGroups.map(([city, objs]) => ({
key: city,
label: (
<Space>
<Typography.Text strong>{city === UNGROUPED ? 'Без города' : city}</Typography.Text>
<Tag>{objs.length}</Tag>
</Space>
),
children: (
<div style={{ margin: '-12px -16px' }}>
{objs.map(renderObjectRow)}
</div>
),
}))
return (
<div>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: 16,
}}
>
<Space align="center" size={16}>
<Typography.Title level={4} style={{ margin: 0 }}>
{mode === 'inventory' ? 'Инвентарь — Объекты' : 'Работа — Объекты'}
</Typography.Title>
<Segmented
value={mode}
options={[
{ label: 'Инвентарь', value: 'inventory', icon: <DatabaseOutlined /> },
{ label: 'Работа', value: 'work', icon: <ControlOutlined /> },
]}
onChange={(val) => navigate(val === 'inventory' ? '/inventory/objects' : '/work/objects')}
/>
</Space>
<Space>
<Input.Search
placeholder="Поиск по названию, городу, клиенту..."
allowClear
style={{ width: 300 }}
onChange={(e) => setSearchText(e.target.value)}
/>
{mode === 'inventory' && (
<Space>
<Button icon={<UploadOutlined />} onClick={openImport}>
Импортировать xlsx
</Button>
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
Добавить
</Button>
</Space>
)}
</Space>
</div>
{isLoading ? (
<Typography.Text type="secondary">Загрузка...</Typography.Text>
) : cityGroups.length === 0 ? (
<Typography.Text type="secondary">Нет объектов</Typography.Text>
) : (
<Collapse
defaultActiveKey={cityGroups.map(([city]) => city)}
items={collapseItems}
/>
)}
{/* 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 */}
<Modal
title={editTarget === null ? 'Новый объект' : `Редактировать: ${editTarget.name}`}
open={modalOpen}
onCancel={() => { setModalOpen(false); form.resetFields() }}
onOk={handleSubmit}
confirmLoading={isSubmitting}
okText={editTarget === null ? 'Создать' : 'Сохранить'}
width={680}
>
<Form form={form} layout="vertical" style={{ marginTop: 16 }}>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="name" label="Название" rules={[{ required: true, message: 'Обязательное поле' }]}>
<Input placeholder="Анапа, Парковка ТЦ Мира" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="server_ip" label="IP сервера объекта">
<Input placeholder="10.23.0.201" />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="city" label="Город">
<AutoComplete
options={(meta?.cities ?? []).map((c) => ({ value: c }))}
filterOption={(input, opt) =>
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
}
placeholder="Санкт-Петербург"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="client" label="Клиент">
<AutoComplete
options={(meta?.clients ?? []).map((c) => ({ value: c }))}
filterOption={(input, opt) =>
(opt?.value ?? '').toLowerCase().includes(input.toLowerCase())
}
placeholder="Авангард"
/>
</Form.Item>
</Col>
</Row>
<Form.Item name="description" label="Описание">
<Input.TextArea rows={2} />
</Form.Item>
<Divider orientation="left" plain>SSH (по умолчанию для устройств)</Divider>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="ssh_user" label="Логин">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="ssh_password" label={editTarget ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={editTarget ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
<Col span={6}>
<Form.Item name="ssh_port" label="Порт" initialValue={22}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Divider orientation="left" plain>
Подключение к БД объекта{' '}
<Typography.Text type="secondary" style={{ fontWeight: 400 }}>
(для синхронизации)
</Typography.Text>
</Divider>
<Row gutter={16}>
<Col span={14}>
<Form.Item name="db_host" label="Хост БД">
<Input placeholder="10.23.0.202" />
</Form.Item>
</Col>
<Col span={10}>
<Form.Item name="db_port" label="Порт" initialValue={5432}>
<InputNumber style={{ width: '100%' }} min={1} max={65535} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_name" label="База данных">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_user" label="Пользователь">
<Input placeholder="caps" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_password" label={editTarget ? 'Новый пароль' : 'Пароль'}>
<Input.Password placeholder={editTarget ? 'Оставьте пустым чтобы не менять' : '••••••••'} />
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={8}>
<Form.Item name="db_table" label="Таблица">
<Input placeholder="racks" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_ip" label="Колонка IP">
<Input placeholder="ip_address" />
</Form.Item>
</Col>
<Col span={8}>
<Form.Item name="db_col_type" label="Колонка типа">
<Input placeholder="device_type" />
</Form.Item>
</Col>
</Row>
<Form.Item name="db_via_tunnel" valuePropName="checked">
<Checkbox>Подключаться через SSH-туннель</Checkbox>
</Form.Item>
</Form>
</Modal>
</div>
)
}