15.12.25 - Этап 3. Детализация карточек и тикеты
All checks were successful
Mirror frontend to backend repo / mirror (push) Successful in 6s
All checks were successful
Mirror frontend to backend repo / mirror (push) Successful in 6s
This commit is contained in:
13
src/App.tsx
13
src/App.tsx
@@ -11,6 +11,11 @@ import SearchPage from '@/pages/SearchPage';
|
|||||||
import TasksPage from '@/pages/TasksPage';
|
import TasksPage from '@/pages/TasksPage';
|
||||||
import CompanyPage from '@/pages/companies/CompanyPage';
|
import CompanyPage from '@/pages/companies/CompanyPage';
|
||||||
|
|
||||||
|
// Импорт детальных страниц
|
||||||
|
import ServerDetails from '@/pages/equipment/ServerDetails';
|
||||||
|
import FiscalDetails from '@/pages/equipment/FiscalDetails';
|
||||||
|
import WorkstationDetails from '@/pages/equipment/WorkstationDetails';
|
||||||
|
|
||||||
import { useUiStore } from '@/store/uiStore';
|
import { useUiStore } from '@/store/uiStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
import { getThemeConfig } from '@/theme/themeConfig';
|
import { getThemeConfig } from '@/theme/themeConfig';
|
||||||
@@ -37,9 +42,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
|||||||
const App: React.FC = () => {
|
const App: React.FC = () => {
|
||||||
const themeMode = useUiStore((state) => state.themeMode);
|
const themeMode = useUiStore((state) => state.themeMode);
|
||||||
|
|
||||||
// Обновляем логику фона: синхронизируем с themeConfig
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
// #000000 для темной, #f0f2f5 для светлой
|
|
||||||
const colorBgLayout = themeMode === 'dark' ? '#000000' : '#f0f2f5';
|
const colorBgLayout = themeMode === 'dark' ? '#000000' : '#f0f2f5';
|
||||||
document.body.style.backgroundColor = colorBgLayout;
|
document.body.style.backgroundColor = colorBgLayout;
|
||||||
}, [themeMode]);
|
}, [themeMode]);
|
||||||
@@ -69,13 +72,13 @@ const App: React.FC = () => {
|
|||||||
|
|
||||||
{/* Роуты для оборудования */}
|
{/* Роуты для оборудования */}
|
||||||
<Route path="servers" element={<div>Список серверов</div>} />
|
<Route path="servers" element={<div>Список серверов</div>} />
|
||||||
<Route path="servers/:id" element={<div>Детали сервера (в разработке)</div>} />
|
<Route path="servers/:id" element={<ServerDetails />} />
|
||||||
|
|
||||||
<Route path="workstations" element={<div>Список РС</div>} />
|
<Route path="workstations" element={<div>Список РС</div>} />
|
||||||
<Route path="workstations/:id" element={<div>Детали РС (в разработке)</div>} />
|
<Route path="workstations/:id" element={<WorkstationDetails />} />
|
||||||
|
|
||||||
<Route path="fiscals" element={<div>Список ФР</div>} />
|
<Route path="fiscals" element={<div>Список ФР</div>} />
|
||||||
<Route path="fiscals/:id" element={<div>Детали ФР (в разработке)</div>} />
|
<Route path="fiscals/:id" element={<FiscalDetails />} />
|
||||||
|
|
||||||
<Route path="admin" element={<div>Админка</div>} />
|
<Route path="admin" element={<div>Админка</div>} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|||||||
@@ -1,11 +1,42 @@
|
|||||||
import apiClient from './axios';
|
import apiClient from './axios';
|
||||||
import { ApiResponse } from '@/types/api';
|
import { ApiResponse, ServerEntity, WorkstationEntity, FiscalEntity, UpdateServerDTO, UpdateWorkstationDTO, UpdateFiscalDTO } from '@/types/api';
|
||||||
|
|
||||||
export const equipmentApi = {
|
export const equipmentApi = {
|
||||||
// Принудительный опрос сервера
|
// --- Servers ---
|
||||||
|
getServer: async (uuid: string) => {
|
||||||
|
const response = await apiClient.get<ApiResponse<ServerEntity>>(`/servers/${uuid}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateServer: async (uuid: string, data: UpdateServerDTO) => {
|
||||||
|
const response = await apiClient.put<ApiResponse<ServerEntity>>(`/servers/${uuid}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
pollServer: async (uuid: string) => {
|
pollServer: async (uuid: string) => {
|
||||||
// Предполагаем, что бэкенд поддерживает этот эндпоинт
|
|
||||||
const response = await apiClient.post<ApiResponse<void>>(`/servers/${uuid}/poll`);
|
const response = await apiClient.post<ApiResponse<void>>(`/servers/${uuid}/poll`);
|
||||||
return response.data;
|
return response.data;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// --- Workstations ---
|
||||||
|
getWorkstation: async (uuid: string) => {
|
||||||
|
const response = await apiClient.get<ApiResponse<WorkstationEntity>>(`/workstations/${uuid}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateWorkstation: async (uuid: string, data: UpdateWorkstationDTO) => {
|
||||||
|
const response = await apiClient.put<ApiResponse<WorkstationEntity>>(`/workstations/${uuid}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
// --- Fiscals ---
|
||||||
|
getFiscal: async (uuid: string) => {
|
||||||
|
const response = await apiClient.get<ApiResponse<FiscalEntity>>(`/fiscals/${uuid}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
updateFiscal: async (uuid: string, data: UpdateFiscalDTO) => {
|
||||||
|
const response = await apiClient.put<ApiResponse<FiscalEntity>>(`/fiscals/${uuid}`, data);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
};
|
};
|
||||||
16
src/api/tickets.ts
Normal file
16
src/api/tickets.ts
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import apiClient from './axios';
|
||||||
|
import { ApiResponse, TicketDTO, TicketListParams } from '@/types/api';
|
||||||
|
|
||||||
|
export const ticketsApi = {
|
||||||
|
getTickets: async (params: TicketListParams = {}) => {
|
||||||
|
const response = await apiClient.get<ApiResponse<TicketDTO[]>>('/tickets', {
|
||||||
|
params,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
getTicket: async (id: number | string) => {
|
||||||
|
const response = await apiClient.get<ApiResponse<TicketDTO>>(`/tickets/${id}`);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
86
src/components/tickets/TicketTable.tsx
Normal file
86
src/components/tickets/TicketTable.tsx
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
import React from 'react';
|
||||||
|
import { Table, Tag, Typography } from 'antd';
|
||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { ticketsApi } from '@/api/tickets';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
interface Props {
|
||||||
|
companyId?: string;
|
||||||
|
limit?: number;
|
||||||
|
showPagination?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const { Text } = Typography;
|
||||||
|
|
||||||
|
const TicketTable: React.FC<Props> = ({ companyId, limit = 10, showPagination = true }) => {
|
||||||
|
const { data, isLoading } = useQuery({
|
||||||
|
queryKey: ['tickets', companyId],
|
||||||
|
queryFn: () => ticketsApi.getTickets({ company_id: companyId, limit }),
|
||||||
|
});
|
||||||
|
|
||||||
|
const getStatusTag = (status: string) => {
|
||||||
|
switch (status) {
|
||||||
|
case 'registered': return <Tag color="blue">Новая</Tag>;
|
||||||
|
case 'inprogress': return <Tag color="processing">В работе</Tag>;
|
||||||
|
case 'wait': return <Tag color="orange">Ожидание</Tag>;
|
||||||
|
case 'closed': return <Tag color="green">Закрыта</Tag>;
|
||||||
|
default: return <Tag>{status}</Tag>;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{
|
||||||
|
title: 'Номер',
|
||||||
|
dataIndex: 'number',
|
||||||
|
key: 'number',
|
||||||
|
width: 100,
|
||||||
|
render: (val: number) => <Text strong>#{val}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Тема',
|
||||||
|
dataIndex: 'subject',
|
||||||
|
key: 'subject',
|
||||||
|
render: (text: string) => <Text style={{ color: '#1890ff', cursor: 'pointer' }}>{text}</Text>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Статус',
|
||||||
|
dataIndex: 'status',
|
||||||
|
key: 'status',
|
||||||
|
width: 120,
|
||||||
|
render: (status: string) => getStatusTag(status),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Дата',
|
||||||
|
dataIndex: 'updated_at',
|
||||||
|
key: 'updated_at',
|
||||||
|
width: 150,
|
||||||
|
render: (date: string) => dayjs(date).format('DD.MM.YYYY HH:mm'),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Исполнитель',
|
||||||
|
dataIndex: 'assignee',
|
||||||
|
key: 'assignee',
|
||||||
|
render: (assignee?: { fullName: string }) => assignee?.fullName || '-',
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Table
|
||||||
|
dataSource={data?.data}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={showPagination ? { pageSize: limit } : false}
|
||||||
|
size="small"
|
||||||
|
onRow={() => ({
|
||||||
|
onClick: () => {
|
||||||
|
// Заглушка перехода, пока нет страницы тикета
|
||||||
|
console.log('Go to ticket details');
|
||||||
|
},
|
||||||
|
style: { cursor: 'pointer' }
|
||||||
|
})}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TicketTable;
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
import React, { useMemo } from 'react';
|
import React, { useMemo } from 'react';
|
||||||
import { useParams, Link } from 'react-router-dom';
|
import { useParams, Link } from 'react-router-dom';
|
||||||
import { useQuery } from '@tanstack/react-query';
|
import { useQuery } from '@tanstack/react-query';
|
||||||
import { Typography, Tabs, Tag, Descriptions, Spin, Empty, Row, Col, Card } from 'antd';
|
import { Typography, Tabs, Tag, Descriptions, Spin, Empty, Row, Col, Card, Button } from 'antd';
|
||||||
import { BankOutlined, CheckCircleOutlined, CloseCircleOutlined, ArrowLeftOutlined } from '@ant-design/icons';
|
import { BankOutlined, CheckCircleOutlined, CloseCircleOutlined, ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons';
|
||||||
import { companiesApi } from '@/api/companies';
|
import { companiesApi } from '@/api/companies';
|
||||||
import { ServerEntity, WorkstationEntity, FiscalEntity } from '@/types/api';
|
import { ServerEntity, WorkstationEntity, FiscalEntity } from '@/types/api';
|
||||||
import ServerCard from '@/components/entities/ServerCard';
|
import ServerCard from '@/components/entities/ServerCard';
|
||||||
import WorkstationCard from '@/components/entities/WorkstationCard';
|
import WorkstationCard from '@/components/entities/WorkstationCard';
|
||||||
import FiscalCard from '@/components/entities/FiscalCard';
|
import FiscalCard from '@/components/entities/FiscalCard';
|
||||||
|
import TicketTable from '@/components/tickets/TicketTable'; // Import
|
||||||
|
|
||||||
const { Title, Text } = Typography;
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
@@ -29,7 +30,6 @@ const CompanyPage: React.FC = () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
const company = companyRes?.data;
|
const company = companyRes?.data;
|
||||||
// Не используем '|| []' здесь, чтобы ссылка на данные была стабильной для useMemo
|
|
||||||
const rawInfrastructure = infraRes?.data;
|
const rawInfrastructure = infraRes?.data;
|
||||||
|
|
||||||
// Группировка инфраструктуры
|
// Группировка инфраструктуры
|
||||||
@@ -38,7 +38,6 @@ const CompanyPage: React.FC = () => {
|
|||||||
const workstations: WorkstationEntity[] = [];
|
const workstations: WorkstationEntity[] = [];
|
||||||
const fiscals: FiscalEntity[] = [];
|
const fiscals: FiscalEntity[] = [];
|
||||||
|
|
||||||
// Безопасно обрабатываем undefined внутри useMemo
|
|
||||||
const list = rawInfrastructure || [];
|
const list = rawInfrastructure || [];
|
||||||
|
|
||||||
list.forEach(item => {
|
list.forEach(item => {
|
||||||
@@ -112,7 +111,17 @@ const CompanyPage: React.FC = () => {
|
|||||||
{
|
{
|
||||||
key: 'tickets',
|
key: 'tickets',
|
||||||
label: 'Тикеты',
|
label: 'Тикеты',
|
||||||
children: <Empty description="Раздел в разработке" style={{ marginTop: 20 }} />,
|
children: (
|
||||||
|
<div style={{ marginTop: 16 }}>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', justifyContent: 'flex-end' }}>
|
||||||
|
<Button type="primary" icon={<PlusOutlined />} onClick={() => console.log('Create Ticket')}>
|
||||||
|
Создать тикет
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
{/* Передаем ID компании для фильтрации */}
|
||||||
|
<TicketTable companyId={company.ID} limit={10} />
|
||||||
|
</div>
|
||||||
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: 'contracts',
|
key: 'contracts',
|
||||||
|
|||||||
144
src/pages/equipment/FiscalDetails.tsx
Normal file
144
src/pages/equipment/FiscalDetails.tsx
Normal file
@@ -0,0 +1,144 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Card, Descriptions, Button, Space, Typography, Spin, Badge, Modal, Form, Input, message } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { equipmentApi } from '@/api/equipment';
|
||||||
|
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||||
|
import { formatRnm } from '@/utils/formatters';
|
||||||
|
import { UpdateFiscalDTO } from '@/types/api';
|
||||||
|
import dayjs from 'dayjs';
|
||||||
|
|
||||||
|
const { Title, Text } = Typography;
|
||||||
|
|
||||||
|
const FiscalDetails: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: fiscalRes, isLoading } = useQuery({
|
||||||
|
queryKey: ['fiscal', id],
|
||||||
|
queryFn: () => equipmentApi.getFiscal(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (values: UpdateFiscalDTO) => equipmentApi.updateFiscal(id!, values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Данные обновлены');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['fiscal', id] });
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => message.error('Ошибка обновления'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||||
|
if (!fiscalRes?.data) return <div>ФР не найден</div>;
|
||||||
|
|
||||||
|
const fiscal = fiscalRes.data;
|
||||||
|
|
||||||
|
// Логика цвета даты окончания ФН
|
||||||
|
const getFnDateColor = (dateStr?: string) => {
|
||||||
|
if (!dateStr) return undefined;
|
||||||
|
const diff = dayjs(dateStr).diff(dayjs(), 'day');
|
||||||
|
if (diff < 0) return 'red';
|
||||||
|
if (diff < 30) return 'orange';
|
||||||
|
return 'green';
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
description: fiscal.description,
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Space align="center">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} />
|
||||||
|
<Space>
|
||||||
|
<div style={{ fontSize: 24, color: '#1890ff' }}>{getEntityIcon('FiscalRegister')}</div>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{fiscal.model_kkt || 'ККТ'}</Title>
|
||||||
|
<Text type="secondary">{fiscal.serial_number}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Badge status={getStatusColor(fiscal.health_status)} text={fiscal.health_status} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>Редактировать</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>Удалить</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
|
||||||
|
{/* Main Info */}
|
||||||
|
<Card title="Информация о ККТ" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={2}>
|
||||||
|
<Descriptions.Item label="РНМ">
|
||||||
|
<Text code>{formatRnm(fiscal.rn_kkt)}</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Заводской номер">{fiscal.serial_number}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Модель">{fiscal.model_kkt}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Описание">{fiscal.description || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* FN Info */}
|
||||||
|
<Card title="Фискальный Накопитель" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={2}>
|
||||||
|
<Descriptions.Item label="Номер ФН">{fiscal.fn_number || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Дата регистрации">
|
||||||
|
{fiscal.fn_registration_date ? dayjs(fiscal.fn_registration_date).format('DD.MM.YYYY') : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Дата окончания">
|
||||||
|
<Text strong style={{ color: getFnDateColor(fiscal.fn_expire_date) }}>
|
||||||
|
{fiscal.fn_expire_date ? dayjs(fiscal.fn_expire_date).format('DD.MM.YYYY') : '-'}
|
||||||
|
</Text>
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Firmware Info */}
|
||||||
|
<Card title="Прошивки и ПО" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={3}>
|
||||||
|
<Descriptions.Item label="Прошивка ФР">{fiscal.fr_firmware || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Загрузчик">{fiscal.fr_downloader || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Драйвер">{fiscal.driver_version || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Legal Info */}
|
||||||
|
<Card title="Юридическое лицо" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={1}>
|
||||||
|
<Descriptions.Item label="Организация">{fiscal.organization_name || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="ИНН">{fiscal.inn || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Адрес установки">{fiscal.address || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Редактирование ФР"
|
||||||
|
open={isEditModalOpen}
|
||||||
|
onCancel={() => setIsEditModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(values) => updateMutation.mutate(values)}>
|
||||||
|
<Form.Item name="description" label="Описание / Заметки">
|
||||||
|
<Input.TextArea rows={4} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default FiscalDetails;
|
||||||
172
src/pages/equipment/ServerDetails.tsx
Normal file
172
src/pages/equipment/ServerDetails.tsx
Normal file
@@ -0,0 +1,172 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Card, Descriptions, Button, Tag, Space, Typography, Spin, Badge, Modal, Form, Input, message } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, EditOutlined, SyncOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { equipmentApi } from '@/api/equipment';
|
||||||
|
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||||
|
import { formatDate } from '@/utils/formatters';
|
||||||
|
import { UpdateServerDTO } from '@/types/api';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const ServerDetails: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: serverRes, isLoading } = useQuery({
|
||||||
|
queryKey: ['server', id],
|
||||||
|
queryFn: () => equipmentApi.getServer(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (values: UpdateServerDTO) => equipmentApi.updateServer(id!, values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Данные сервера обновлены');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['server', id] });
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => message.error('Ошибка обновления'),
|
||||||
|
});
|
||||||
|
|
||||||
|
const pollMutation = useMutation({
|
||||||
|
mutationFn: () => equipmentApi.pollServer(id!),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Запрос на опрос отправлен');
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||||
|
if (!serverRes?.data) return <div>Сервер не найден</div>;
|
||||||
|
|
||||||
|
const server = serverRes.data;
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
device_name: server.device_name,
|
||||||
|
ip: server.ip,
|
||||||
|
anydesk: server.anydesk,
|
||||||
|
teamviewer: server.teamviewer,
|
||||||
|
description: server.description,
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{/* Header */}
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Space align="center">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} />
|
||||||
|
<Space>
|
||||||
|
<div style={{ fontSize: 24, color: '#1890ff' }}>{getEntityIcon('Server')}</div>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{server.device_name || server.server_name || 'Server'}</Title>
|
||||||
|
<Text type="secondary">{server.uuid}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Tag color={getStatusColor(server.operational_status) === 'success' ? 'green' : 'red'}>
|
||||||
|
{server.operational_status?.toUpperCase()}
|
||||||
|
</Tag>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
icon={<SyncOutlined spin={pollMutation.isPending} />}
|
||||||
|
onClick={() => pollMutation.mutate()}
|
||||||
|
>
|
||||||
|
Опросить
|
||||||
|
</Button>
|
||||||
|
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>Редактировать</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>Удалить</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div style={{ display: 'grid', gridTemplateColumns: '2fr 1fr', gap: 24 }}>
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
|
||||||
|
{/* Main Info */}
|
||||||
|
<Card title="Основная информация" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={2}>
|
||||||
|
<Descriptions.Item label="IP Адрес">
|
||||||
|
<Paragraph copyable={{ text: server.ip }} style={{ margin: 0 }}>{server.ip || '-'}</Paragraph>
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Health Status">
|
||||||
|
<Badge status={getStatusColor(server.health_status)} text={server.health_status} />
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Unique ID">{server.unique_id || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="CRM ID">{server.crm_id || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Описание" span={2}>{server.description || '-'}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Software Info */}
|
||||||
|
<Card title="Программное обеспечение" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={2}>
|
||||||
|
<Descriptions.Item label="Версия сервера">{server.server_version || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Редакция">{server.server_edition || '-'}</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="Посл. опрос">{formatDate(server.last_polled_at)}</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space direction="vertical" size="middle" style={{ width: '100%' }}>
|
||||||
|
{/* Access Info */}
|
||||||
|
<Card title="Удаленный доступ" className="glass-panel" size="small">
|
||||||
|
<Descriptions column={1} layout="vertical">
|
||||||
|
<Descriptions.Item label="AnyDesk">
|
||||||
|
{server.anydesk ? <Paragraph copyable>{server.anydesk}</Paragraph> : <Text type="secondary">-</Text>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="TeamViewer">
|
||||||
|
{server.teamviewer ? <Paragraph copyable>{server.teamviewer}</Paragraph> : <Text type="secondary">-</Text>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="LiteManager">
|
||||||
|
{server.litemanager ? <Paragraph copyable>{server.litemanager}</Paragraph> : <Text type="secondary">-</Text>}
|
||||||
|
</Descriptions.Item>
|
||||||
|
{server.partners_link && (
|
||||||
|
<Descriptions.Item label="Кабинет">
|
||||||
|
<Button type="link" href={server.partners_link} target="_blank" style={{ padding: 0 }}>
|
||||||
|
Перейти в кабинет дилера
|
||||||
|
</Button>
|
||||||
|
</Descriptions.Item>
|
||||||
|
)}
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Edit Modal */}
|
||||||
|
<Modal
|
||||||
|
title="Редактирование сервера"
|
||||||
|
open={isEditModalOpen}
|
||||||
|
onCancel={() => setIsEditModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(values) => updateMutation.mutate(values)}>
|
||||||
|
<Form.Item name="device_name" label="Имя устройства">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="ip" label="IP адрес">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="anydesk" label="AnyDesk">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="teamviewer" label="TeamViewer">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="Описание">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ServerDetails;
|
||||||
114
src/pages/equipment/WorkstationDetails.tsx
Normal file
114
src/pages/equipment/WorkstationDetails.tsx
Normal file
@@ -0,0 +1,114 @@
|
|||||||
|
import React, { useState } from 'react';
|
||||||
|
import { useParams, useNavigate } from 'react-router-dom';
|
||||||
|
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import { Card, Descriptions, Button, Space, Typography, Spin, Badge, Modal, Form, Input, message } from 'antd';
|
||||||
|
import { ArrowLeftOutlined, EditOutlined, DeleteOutlined } from '@ant-design/icons';
|
||||||
|
import { equipmentApi } from '@/api/equipment';
|
||||||
|
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||||
|
import { UpdateWorkstationDTO } from '@/types/api';
|
||||||
|
|
||||||
|
const { Title, Text, Paragraph } = Typography;
|
||||||
|
|
||||||
|
const WorkstationDetails: React.FC = () => {
|
||||||
|
const { id } = useParams<{ id: string }>();
|
||||||
|
const navigate = useNavigate();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const [isEditModalOpen, setIsEditModalOpen] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
|
|
||||||
|
const { data: wsRes, isLoading } = useQuery({
|
||||||
|
queryKey: ['workstation', id],
|
||||||
|
queryFn: () => equipmentApi.getWorkstation(id!),
|
||||||
|
enabled: !!id,
|
||||||
|
});
|
||||||
|
|
||||||
|
const updateMutation = useMutation({
|
||||||
|
mutationFn: (values: UpdateWorkstationDTO) => equipmentApi.updateWorkstation(id!, values),
|
||||||
|
onSuccess: () => {
|
||||||
|
message.success('Данные обновлены');
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['workstation', id] });
|
||||||
|
setIsEditModalOpen(false);
|
||||||
|
},
|
||||||
|
onError: () => message.error('Ошибка обновления'),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||||
|
if (!wsRes?.data) return <div>Рабочая станция не найдена</div>;
|
||||||
|
|
||||||
|
const ws = wsRes.data;
|
||||||
|
|
||||||
|
const handleEdit = () => {
|
||||||
|
form.setFieldsValue({
|
||||||
|
device_name: ws.device_name,
|
||||||
|
anydesk: ws.anydesk,
|
||||||
|
teamviewer: ws.teamviewer,
|
||||||
|
description: ws.description,
|
||||||
|
});
|
||||||
|
setIsEditModalOpen(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<div style={{ marginBottom: 16, display: 'flex', alignItems: 'center', justifyContent: 'space-between' }}>
|
||||||
|
<Space align="center">
|
||||||
|
<Button icon={<ArrowLeftOutlined />} onClick={() => navigate(-1)} />
|
||||||
|
<Space>
|
||||||
|
<div style={{ fontSize: 24, color: '#1890ff' }}>{getEntityIcon('Workstation')}</div>
|
||||||
|
<div>
|
||||||
|
<Title level={4} style={{ margin: 0 }}>{ws.device_name || 'Workstation'}</Title>
|
||||||
|
<Text type="secondary">{ws.uuid}</Text>
|
||||||
|
</div>
|
||||||
|
</Space>
|
||||||
|
<Badge status={getStatusColor(ws.health_status)} text={ws.health_status} />
|
||||||
|
</Space>
|
||||||
|
|
||||||
|
<Space>
|
||||||
|
<Button type="primary" icon={<EditOutlined />} onClick={handleEdit}>Редактировать</Button>
|
||||||
|
<Button danger icon={<DeleteOutlined />}>Удалить</Button>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card title="Детали рабочей станции" className="glass-panel" size="small">
|
||||||
|
<Descriptions bordered column={1}>
|
||||||
|
<Descriptions.Item label="Описание">
|
||||||
|
{ws.description || '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="AnyDesk">
|
||||||
|
{ws.anydesk ? <Paragraph copyable>{ws.anydesk}</Paragraph> : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="TeamViewer">
|
||||||
|
{ws.teamviewer ? <Paragraph copyable>{ws.teamviewer}</Paragraph> : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
<Descriptions.Item label="LiteManager">
|
||||||
|
{ws.litemanager ? <Paragraph copyable>{ws.litemanager}</Paragraph> : '-'}
|
||||||
|
</Descriptions.Item>
|
||||||
|
</Descriptions>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Modal
|
||||||
|
title="Редактирование РС"
|
||||||
|
open={isEditModalOpen}
|
||||||
|
onCancel={() => setIsEditModalOpen(false)}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={updateMutation.isPending}
|
||||||
|
>
|
||||||
|
<Form form={form} layout="vertical" onFinish={(values) => updateMutation.mutate(values)}>
|
||||||
|
<Form.Item name="device_name" label="Имя устройства">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="anydesk" label="AnyDesk">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="teamviewer" label="TeamViewer">
|
||||||
|
<Input />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item name="description" label="Описание">
|
||||||
|
<Input.TextArea rows={3} />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default WorkstationDetails;
|
||||||
@@ -65,7 +65,6 @@ export interface ServerEntity {
|
|||||||
|
|
||||||
crm_id?: string;
|
crm_id?: string;
|
||||||
|
|
||||||
// Добавлено поле address
|
|
||||||
address?: string;
|
address?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@@ -84,7 +83,6 @@ export interface WorkstationEntity {
|
|||||||
health_status: 'ok' | 'attention_required' | 'locked';
|
health_status: 'ok' | 'attention_required' | 'locked';
|
||||||
status_details?: unknown;
|
status_details?: unknown;
|
||||||
|
|
||||||
// Добавлено поле description
|
|
||||||
description?: string;
|
description?: string;
|
||||||
address?: string;
|
address?: string;
|
||||||
|
|
||||||
@@ -113,7 +111,6 @@ export interface FiscalEntity {
|
|||||||
health_status: 'ok' | 'attention_required' | 'locked';
|
health_status: 'ok' | 'attention_required' | 'locked';
|
||||||
status_details?: unknown;
|
status_details?: unknown;
|
||||||
|
|
||||||
// Добавлено поле address
|
|
||||||
address?: string;
|
address?: string;
|
||||||
description?: string;
|
description?: string;
|
||||||
|
|
||||||
@@ -163,4 +160,50 @@ export interface TaskResolutionPayload {
|
|||||||
new_owner_id?: string;
|
new_owner_id?: string;
|
||||||
[key: string]: unknown;
|
[key: string]: unknown;
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// --- Tickets DTO ---
|
||||||
|
export interface TicketDTO {
|
||||||
|
id: number;
|
||||||
|
number: number;
|
||||||
|
subject: string;
|
||||||
|
description?: string;
|
||||||
|
status: 'registered' | 'inprogress' | 'closed' | 'wait';
|
||||||
|
priority?: string;
|
||||||
|
created_at: string;
|
||||||
|
updated_at: string;
|
||||||
|
assignee?: {
|
||||||
|
id: number;
|
||||||
|
fullName: string;
|
||||||
|
};
|
||||||
|
company_id: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TicketListParams {
|
||||||
|
company_id?: string;
|
||||||
|
limit?: number;
|
||||||
|
offset?: number;
|
||||||
|
status?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// DTO для обновления оборудования
|
||||||
|
export interface UpdateServerDTO {
|
||||||
|
device_name?: string;
|
||||||
|
ip?: string;
|
||||||
|
anydesk?: string;
|
||||||
|
teamviewer?: string;
|
||||||
|
description?: string;
|
||||||
|
// Добавляем другие поля по мере необходимости
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateWorkstationDTO {
|
||||||
|
device_name?: string;
|
||||||
|
anydesk?: string;
|
||||||
|
teamviewer?: string;
|
||||||
|
description?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdateFiscalDTO {
|
||||||
|
description?: string;
|
||||||
|
// ККТ поля обычно read-only, т.к. приходят из железа, но description можно править
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user