605 lines
21 KiB
TypeScript
605 lines
21 KiB
TypeScript
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>
|
||
)
|
||
}
|