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(null) const [searchText, setSearchText] = useState('') const [form] = Form.useForm() // Import modal state const [importOpen, setImportOpen] = useState(false) const [importFile, setImportFile] = useState(null) const [importPreview, setImportPreview] = useState(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() 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) => (
{obj.client && {obj.client}} {obj.server_ip && {obj.server_ip}} {obj.tags.map((t) => ( {t.name} ))} {!obj.is_active && Неактивен} {obj.device_count} уст. изм. {dayjs(obj.updated_at).format('DD.MM.YY HH:mm')} {mode === 'inventory' && (
) const collapseItems = cityGroups.map(([city, objs]) => ({ key: city, label: ( {city === UNGROUPED ? 'Без города' : city} {objs.length} ), children: (
{objs.map(renderObjectRow)}
), })) return (
{mode === 'inventory' ? 'Инвентарь — Объекты' : 'Работа — Объекты'} }, { label: 'Работа', value: 'work', icon: }, ]} onChange={(val) => navigate(val === 'inventory' ? '/inventory/objects' : '/work/objects')} /> setSearchText(e.target.value)} /> {mode === 'inventory' && ( )}
{isLoading ? ( Загрузка... ) : cityGroups.length === 0 ? ( Нет объектов ) : ( city)} items={collapseItems} /> )} {/* Import xlsx modal */} setImportOpen(false)} width={900} footer={ importStep === 'preview' && importPreview ? [ , , ] : null } > {importStep === 'upload' && (
{ handleImportFileChange(file) return false }} style={{ padding: '16px 32px' }} >

Перетащите .xlsx файл или нажмите для выбора

Поддерживается шаблон objects_import_template.xlsx

{previewMutation.isPending && ( Анализируем файл... )}
)} {importStep === 'preview' && importPreview && (
{/* Summary */} Будет создано: {importPreview.would_create.length} {importPreview.would_skip.length > 0 && ( Пропущено (дубли): {importPreview.would_skip.length} )} {importPreview.errors.length > 0 && ( Ошибок: {importPreview.errors.length} )} {/* Would create table */} {importPreview.would_create.length > 0 && ( <> Будет создано {v}, }, { title: 'Клиент', dataIndex: 'client', width: 130, render: (v) => v ?? '—' }, { title: 'SSH', dataIndex: 'ssh_user', width: 80, render: (v: string) => {v}, }, ]} /> )} {/* Skipped */} {importPreview.would_skip.length > 0 && ( <> Пропущены (дубликаты по IP)
{v}, }, { title: 'Причина', dataIndex: 'reason' }, ]} /> )} {/* Errors */} {importPreview.errors.length > 0 && ( <>
)} )} {/* Create / Edit modal — only relevant in inventory mode */} { setModalOpen(false); form.resetFields() }} onOk={handleSubmit} confirmLoading={isSubmitting} okText={editTarget === null ? 'Создать' : 'Сохранить'} width={680} >
({ value: c }))} filterOption={(input, opt) => (opt?.value ?? '').toLowerCase().includes(input.toLowerCase()) } placeholder="Санкт-Петербург" /> ({ value: c }))} filterOption={(input, opt) => (opt?.value ?? '').toLowerCase().includes(input.toLowerCase()) } placeholder="Авангард" /> SSH (по умолчанию для устройств) Подключение к БД объекта{' '} (для синхронизации) Подключаться через SSH-туннель ) }