diff --git a/src/App.tsx b/src/App.tsx index b91afb2..662f609 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -11,6 +11,11 @@ import SearchPage from '@/pages/SearchPage'; import TasksPage from '@/pages/TasksPage'; 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 { useAuthStore } from '@/store/authStore'; import { getThemeConfig } from '@/theme/themeConfig'; @@ -37,9 +42,7 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => { const App: React.FC = () => { const themeMode = useUiStore((state) => state.themeMode); - // Обновляем логику фона: синхронизируем с themeConfig useEffect(() => { - // #000000 для темной, #f0f2f5 для светлой const colorBgLayout = themeMode === 'dark' ? '#000000' : '#f0f2f5'; document.body.style.backgroundColor = colorBgLayout; }, [themeMode]); @@ -69,13 +72,13 @@ const App: React.FC = () => { {/* Роуты для оборудования */} Список серверов} /> - Детали сервера (в разработке)} /> + } /> Список РС} /> - Детали РС (в разработке)} /> + } /> Список ФР} /> - Детали ФР (в разработке)} /> + } /> Админка} /> diff --git a/src/api/equipment.ts b/src/api/equipment.ts index 34c7835..ca1ee93 100644 --- a/src/api/equipment.ts +++ b/src/api/equipment.ts @@ -1,11 +1,42 @@ import apiClient from './axios'; -import { ApiResponse } from '@/types/api'; +import { ApiResponse, ServerEntity, WorkstationEntity, FiscalEntity, UpdateServerDTO, UpdateWorkstationDTO, UpdateFiscalDTO } from '@/types/api'; export const equipmentApi = { - // Принудительный опрос сервера + // --- Servers --- + getServer: async (uuid: string) => { + const response = await apiClient.get>(`/servers/${uuid}`); + return response.data; + }, + + updateServer: async (uuid: string, data: UpdateServerDTO) => { + const response = await apiClient.put>(`/servers/${uuid}`, data); + return response.data; + }, + pollServer: async (uuid: string) => { - // Предполагаем, что бэкенд поддерживает этот эндпоинт const response = await apiClient.post>(`/servers/${uuid}/poll`); return response.data; }, + + // --- Workstations --- + getWorkstation: async (uuid: string) => { + const response = await apiClient.get>(`/workstations/${uuid}`); + return response.data; + }, + + updateWorkstation: async (uuid: string, data: UpdateWorkstationDTO) => { + const response = await apiClient.put>(`/workstations/${uuid}`, data); + return response.data; + }, + + // --- Fiscals --- + getFiscal: async (uuid: string) => { + const response = await apiClient.get>(`/fiscals/${uuid}`); + return response.data; + }, + + updateFiscal: async (uuid: string, data: UpdateFiscalDTO) => { + const response = await apiClient.put>(`/fiscals/${uuid}`, data); + return response.data; + }, }; \ No newline at end of file diff --git a/src/api/tickets.ts b/src/api/tickets.ts new file mode 100644 index 0000000..3a2a6c5 --- /dev/null +++ b/src/api/tickets.ts @@ -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>('/tickets', { + params, + }); + return response.data; + }, + + getTicket: async (id: number | string) => { + const response = await apiClient.get>(`/tickets/${id}`); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/components/tickets/TicketTable.tsx b/src/components/tickets/TicketTable.tsx new file mode 100644 index 0000000..a442e1c --- /dev/null +++ b/src/components/tickets/TicketTable.tsx @@ -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 = ({ 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 Новая; + case 'inprogress': return В работе; + case 'wait': return Ожидание; + case 'closed': return Закрыта; + default: return {status}; + } + }; + + const columns = [ + { + title: 'Номер', + dataIndex: 'number', + key: 'number', + width: 100, + render: (val: number) => #{val}, + }, + { + title: 'Тема', + dataIndex: 'subject', + key: 'subject', + render: (text: string) => {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 ( + ({ + onClick: () => { + // Заглушка перехода, пока нет страницы тикета + console.log('Go to ticket details'); + }, + style: { cursor: 'pointer' } + })} + /> + ); +}; + +export default TicketTable; \ No newline at end of file diff --git a/src/pages/companies/CompanyPage.tsx b/src/pages/companies/CompanyPage.tsx index 6ca66f8..ac2dd31 100644 --- a/src/pages/companies/CompanyPage.tsx +++ b/src/pages/companies/CompanyPage.tsx @@ -1,13 +1,14 @@ import React, { useMemo } from 'react'; import { useParams, Link } from 'react-router-dom'; import { useQuery } from '@tanstack/react-query'; -import { Typography, Tabs, Tag, Descriptions, Spin, Empty, Row, Col, Card } from 'antd'; -import { BankOutlined, CheckCircleOutlined, CloseCircleOutlined, ArrowLeftOutlined } from '@ant-design/icons'; +import { Typography, Tabs, Tag, Descriptions, Spin, Empty, Row, Col, Card, Button } from 'antd'; +import { BankOutlined, CheckCircleOutlined, CloseCircleOutlined, ArrowLeftOutlined, PlusOutlined } from '@ant-design/icons'; import { companiesApi } from '@/api/companies'; import { ServerEntity, WorkstationEntity, FiscalEntity } from '@/types/api'; import ServerCard from '@/components/entities/ServerCard'; import WorkstationCard from '@/components/entities/WorkstationCard'; import FiscalCard from '@/components/entities/FiscalCard'; +import TicketTable from '@/components/tickets/TicketTable'; // Import const { Title, Text } = Typography; @@ -29,7 +30,6 @@ const CompanyPage: React.FC = () => { }); const company = companyRes?.data; - // Не используем '|| []' здесь, чтобы ссылка на данные была стабильной для useMemo const rawInfrastructure = infraRes?.data; // Группировка инфраструктуры @@ -38,7 +38,6 @@ const CompanyPage: React.FC = () => { const workstations: WorkstationEntity[] = []; const fiscals: FiscalEntity[] = []; - // Безопасно обрабатываем undefined внутри useMemo const list = rawInfrastructure || []; list.forEach(item => { @@ -112,7 +111,17 @@ const CompanyPage: React.FC = () => { { key: 'tickets', label: 'Тикеты', - children: , + children: ( +
+
+ +
+ {/* Передаем ID компании для фильтрации */} + +
+ ), }, { key: 'contracts', diff --git a/src/pages/equipment/FiscalDetails.tsx b/src/pages/equipment/FiscalDetails.tsx new file mode 100644 index 0000000..aa3e311 --- /dev/null +++ b/src/pages/equipment/FiscalDetails.tsx @@ -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
; + if (!fiscalRes?.data) return
ФР не найден
; + + 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 ( +
+
+ + + + +
+ + + + {/* Main Info */} + + + + {formatRnm(fiscal.rn_kkt)} + + {fiscal.serial_number} + {fiscal.model_kkt} + {fiscal.description || '-'} + + + + {/* FN Info */} + + + {fiscal.fn_number || '-'} + + {fiscal.fn_registration_date ? dayjs(fiscal.fn_registration_date).format('DD.MM.YYYY') : '-'} + + + + {fiscal.fn_expire_date ? dayjs(fiscal.fn_expire_date).format('DD.MM.YYYY') : '-'} + + + + + + {/* Firmware Info */} + + + {fiscal.fr_firmware || '-'} + {fiscal.fr_downloader || '-'} + {fiscal.driver_version || '-'} + + + + {/* Legal Info */} + + + {fiscal.organization_name || '-'} + {fiscal.inn || '-'} + {fiscal.address || '-'} + + + + + setIsEditModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={updateMutation.isPending} + > +
updateMutation.mutate(values)}> + + + + +
+
+ ); +}; + +export default FiscalDetails; \ No newline at end of file diff --git a/src/pages/equipment/ServerDetails.tsx b/src/pages/equipment/ServerDetails.tsx new file mode 100644 index 0000000..ce227ff --- /dev/null +++ b/src/pages/equipment/ServerDetails.tsx @@ -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
; + if (!serverRes?.data) return
Сервер не найден
; + + 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 ( +
+ {/* Header */} +
+ + + + + +
+ +
+ + + {/* Main Info */} + + + + {server.ip || '-'} + + + + + {server.unique_id || '-'} + {server.crm_id || '-'} + {server.description || '-'} + + + + {/* Software Info */} + + + {server.server_version || '-'} + {server.server_edition || '-'} + {formatDate(server.last_polled_at)} + + + + + + {/* Access Info */} + + + + {server.anydesk ? {server.anydesk} : -} + + + {server.teamviewer ? {server.teamviewer} : -} + + + {server.litemanager ? {server.litemanager} : -} + + {server.partners_link && ( + + + + )} + + + +
+ + {/* Edit Modal */} + setIsEditModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={updateMutation.isPending} + > +
updateMutation.mutate(values)}> + + + + + + + + + + + + + + + + +
+
+ ); +}; + +export default ServerDetails; \ No newline at end of file diff --git a/src/pages/equipment/WorkstationDetails.tsx b/src/pages/equipment/WorkstationDetails.tsx new file mode 100644 index 0000000..fa617b9 --- /dev/null +++ b/src/pages/equipment/WorkstationDetails.tsx @@ -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
; + if (!wsRes?.data) return
Рабочая станция не найдена
; + + 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 ( +
+
+ + + + +
+ + + + + {ws.description || '-'} + + + {ws.anydesk ? {ws.anydesk} : '-'} + + + {ws.teamviewer ? {ws.teamviewer} : '-'} + + + {ws.litemanager ? {ws.litemanager} : '-'} + + + + + setIsEditModalOpen(false)} + onOk={() => form.submit()} + confirmLoading={updateMutation.isPending} + > +
updateMutation.mutate(values)}> + + + + + + + + + + + + + +
+
+ ); +}; + +export default WorkstationDetails; \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts index 4f231f4..5e4c3ca 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -65,7 +65,6 @@ export interface ServerEntity { crm_id?: string; - // Добавлено поле address address?: string; description?: string; @@ -84,7 +83,6 @@ export interface WorkstationEntity { health_status: 'ok' | 'attention_required' | 'locked'; status_details?: unknown; - // Добавлено поле description description?: string; address?: string; @@ -113,7 +111,6 @@ export interface FiscalEntity { health_status: 'ok' | 'attention_required' | 'locked'; status_details?: unknown; - // Добавлено поле address address?: string; description?: string; @@ -163,4 +160,50 @@ export interface TaskResolutionPayload { new_owner_id?: string; [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 можно править } \ No newline at end of file