diff --git a/README.md b/README.md deleted file mode 100644 index d2e7761..0000000 --- a/README.md +++ /dev/null @@ -1,73 +0,0 @@ -# React + TypeScript + Vite - -This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. - -Currently, two official plugins are available: - -- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Babel](https://babeljs.io/) (or [oxc](https://oxc.rs) when used in [rolldown-vite](https://vite.dev/guide/rolldown)) for Fast Refresh -- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh - -## React Compiler - -The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). - -## Expanding the ESLint configuration - -If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: - -```js -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - - // Remove tseslint.configs.recommended and replace with this - tseslint.configs.recommendedTypeChecked, - // Alternatively, use this for stricter rules - tseslint.configs.strictTypeChecked, - // Optionally, add this for stylistic rules - tseslint.configs.stylisticTypeChecked, - - // Other configs... - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` - -You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: - -```js -// eslint.config.js -import reactX from 'eslint-plugin-react-x' -import reactDom from 'eslint-plugin-react-dom' - -export default defineConfig([ - globalIgnores(['dist']), - { - files: ['**/*.{ts,tsx}'], - extends: [ - // Other configs... - // Enable lint rules for React - reactX.configs['recommended-typescript'], - // Enable lint rules for React DOM - reactDom.configs.recommended, - ], - languageOptions: { - parserOptions: { - project: ['./tsconfig.node.json', './tsconfig.app.json'], - tsconfigRootDir: import.meta.dirname, - }, - // other options... - }, - }, -]) -``` diff --git a/src/App.tsx b/src/App.tsx index b5dd5c1..19a380c 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -7,6 +7,8 @@ import ruRU from 'antd/locale/ru_RU'; import MainLayout from '@/components/layout/MainLayout'; import LoginPage from '@/pages/auth/LoginPage'; import Dashboard from '@/pages/Dashboard'; +import SearchPage from '@/pages/SearchPage'; +import TasksPage from '@/pages/TasksPage'; import { useUiStore } from '@/store/uiStore'; import { useAuthStore } from '@/store/authStore'; @@ -58,7 +60,8 @@ const App: React.FC = () => { }> {/* Вложенные роуты внутри MainLayout */} } /> - Задачи} /> + } /> + } /> Тикеты} /> Компании} /> Серверы} /> diff --git a/src/api/search.ts b/src/api/search.ts new file mode 100644 index 0000000..37fc7ee --- /dev/null +++ b/src/api/search.ts @@ -0,0 +1,11 @@ +import apiClient from './axios'; +import { ApiResponse, SearchResponseData } from '@/types/api'; + +export const searchApi = { + searchEntities: async (term: string, limit = 50) => { + const response = await apiClient.get>('/search', { + params: { term, limit }, + }); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/api/tasks.ts b/src/api/tasks.ts new file mode 100644 index 0000000..a7ee0e3 --- /dev/null +++ b/src/api/tasks.ts @@ -0,0 +1,24 @@ +import apiClient from './axios'; +import { ApiResponse, TaskDTO, TaskResolutionPayload } from '@/types/api'; + +export const tasksApi = { + getTasks: async (status?: string, page = 1, limit = 50) => { + const offset = (page - 1) * limit; + const response = await apiClient.get>('/tasks', { + params: { status, limit, offset }, + }); + return response.data; + }, + + resolveTask: async (id: number, payload: TaskResolutionPayload) => { + const response = await apiClient.post>(`/tasks/${id}/resolve`, payload); + return response.data; + }, + + createEntityInSd: async (id: number, entityType: string) => { + const response = await apiClient.post>(`/tasks/${id}/create-entity-in-sd`, { + entity_type: entityType, + }); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/components/common/HeaderSearch.tsx b/src/components/common/HeaderSearch.tsx new file mode 100644 index 0000000..6c994ba --- /dev/null +++ b/src/components/common/HeaderSearch.tsx @@ -0,0 +1,28 @@ +import React from 'react'; +import { Input } from 'antd'; +import { useNavigate, useSearchParams } from 'react-router-dom'; + +const HeaderSearch: React.FC = () => { + const navigate = useNavigate(); + const [searchParams] = useSearchParams(); + const currentTerm = searchParams.get('term') || ''; + + const onSearch = (value: string) => { + if (value.trim()) { + navigate(`/search?term=${encodeURIComponent(value.trim())}`); + } + }; + + return ( + + ); +}; + +export default HeaderSearch; \ No newline at end of file diff --git a/src/components/common/TaskStatusTag.tsx b/src/components/common/TaskStatusTag.tsx new file mode 100644 index 0000000..7d910f9 --- /dev/null +++ b/src/components/common/TaskStatusTag.tsx @@ -0,0 +1,51 @@ +import React from 'react'; +import { Tag } from 'antd'; +import { + CheckCircleOutlined, + SyncOutlined, + CloseCircleOutlined, + ExclamationCircleOutlined +} from '@ant-design/icons'; +import { TaskStatus } from '@/types/api'; + +interface Props { + status: TaskStatus | string; // string для универсальности, если придет неизвестный статус +} + +const TaskStatusTag: React.FC = ({ status }) => { + let color = 'default'; + let label = status; + let icon = null; + + switch (status) { + case 'new': + color = 'blue'; + label = 'Новая'; + icon = ; + break; + case 'resolved': + color = 'green'; + label = 'Решена'; + icon = ; + break; + case 'rejected': + color = 'red'; + label = 'Отклонена'; + icon = ; + break; + case 'pending_sd_action': + color = 'orange'; + label = 'В обработке SD'; + icon = ; + break; + case 'sd_error': + color = 'volcano'; + label = 'Ошибка SD'; + icon = ; + break; + } + + return {label}; +}; + +export default TaskStatusTag; \ No newline at end of file diff --git a/src/components/layout/MainLayout.tsx b/src/components/layout/MainLayout.tsx index 0fe456e..ed8cb30 100644 --- a/src/components/layout/MainLayout.tsx +++ b/src/components/layout/MainLayout.tsx @@ -15,6 +15,7 @@ import { SunOutlined, MoonOutlined } from '@ant-design/icons'; +import HeaderSearch from '@/components/common/HeaderSearch'; import { useUiStore } from '@/store/uiStore'; import { useAuthStore } from '@/store/authStore'; @@ -114,11 +115,11 @@ const MainLayout: React.FC = () => {
{ onClick={() => setCollapsed(!collapsed)} style={{ fontSize: '16px', width: 64, height: 64 }} /> - - {/* Заголовок страницы можно сделать динамическим позже */} - Dashboard - + + {/* ЦЕНТР: Глобальный поиск */} +
+
diff --git a/src/features/tasks/TaskResolutionModal.tsx b/src/features/tasks/TaskResolutionModal.tsx new file mode 100644 index 0000000..b1cfeed --- /dev/null +++ b/src/features/tasks/TaskResolutionModal.tsx @@ -0,0 +1,145 @@ +import React, { useState } from 'react'; +import { Modal, Button, Input, Descriptions, Space, message, Alert } from 'antd'; +import { TaskDTO, TaskResolutionPayload } from '@/types/api'; +import { tasksApi } from '@/api/tasks'; +import { useMutation, useQueryClient } from '@tanstack/react-query'; +import TaskStatusTag from '@/components/common/TaskStatusTag'; + +interface Props { + task: TaskDTO | null; + visible: boolean; + onClose: () => void; +} + +const TaskResolutionModal: React.FC = ({ task, visible, onClose }) => { + const [comment, setComment] = useState(''); + const queryClient = useQueryClient(); + + // Мутация для решения задачи + const resolveMutation = useMutation({ + mutationFn: (payload: TaskResolutionPayload) => + tasksApi.resolveTask(task!.id, payload), + onSuccess: () => { + message.success('Задача обработана'); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + onClose(); + setComment(''); + }, + onError: () => message.error('Ошибка при обработке задачи'), + }); + + // Мутация для создания сущности в SD + const createSdMutation = useMutation({ + mutationFn: () => tasksApi.createEntityInSd(task!.id, task!.entity_type), + onSuccess: () => { + message.success('Запрос на создание в SD отправлен'); + queryClient.invalidateQueries({ queryKey: ['tasks'] }); + onClose(); + }, + }); + + if (!task) return null; + + const handleResolve = () => { + // В реальном приложении здесь может быть сложная форма выбора действия (Action) + // Для примера берем action='create' или 'approve' по умолчанию + resolveMutation.mutate({ + status: 'resolved', + comment, + resolution_payload: { action: 'create' } + }); + }; + + const handleReject = () => { + resolveMutation.mutate({ + status: 'rejected', + comment, + resolution_payload: { action: 'reject' } // Зависит от бэкенда + }); + }; + + const renderDetails = () => { + // Рендерим детали динамически, так как структура зависит от типа задачи + return ( + + {Object.entries(task.details).map(([key, value]) => { + // Если значение - объект, рендерим его как JSON string (упрощенно) + const displayValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value); + return ( + +
+                {displayValue}
+              
+
+ ); + })} +
+ ); + }; + + return ( + +
+ + + {new Date(task.created_at).toLocaleString()} + +
+ + {task.status === 'new' && ( + + )} + + {renderDetails()} + +
+ setComment(e.target.value)} + style={{ marginBottom: 16 }} + disabled={task.status !== 'new'} + /> + + {task.status === 'new' && ( + + + + {/* Спец кнопка для Add Equipment, если еще не отправлено в SD */} + {task.task_type === 'add_equipment' && ( + + )} + + )} +
+
+ ); +}; + +export default TaskResolutionModal; \ No newline at end of file diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx new file mode 100644 index 0000000..65a0d20 --- /dev/null +++ b/src/pages/SearchPage.tsx @@ -0,0 +1,104 @@ +import React from 'react'; +import { useSearchParams } from 'react-router-dom'; +import { useQuery } from '@tanstack/react-query'; +import { Card, List, Typography, Spin, Empty, Badge, Button, Space } from 'antd'; +import { searchApi } from '@/api/search'; +import { getEntityIcon, getEntityLabel, getStatusColor } from '@/utils/mappers'; +import { ArrowRightOutlined } from '@ant-design/icons'; +import { SearchFoundEntity, EntityData } from '@/types/api'; + +const { Title, Text } = Typography; + +const SearchPage: React.FC = () => { + const [searchParams] = useSearchParams(); + const term = searchParams.get('term') || ''; + + const { data, isLoading, isError } = useQuery({ + queryKey: ['search', term], + queryFn: () => searchApi.searchEntities(term), + enabled: !!term, + staleTime: 1000 * 60, + }); + + if (!term) { + return ( +
+ Введите запрос для поиска + IP адрес, серийный номер, ИНН или название +
+ ); + } + + if (isLoading) return
; + if (isError) return Ошибка при выполнении поиска; + + const results = data?.data?.search_results || []; + + return ( +
+ Результаты поиска: "{term}" + + {results.length === 0 ? ( + + ) : ( + + {results.map((group) => ( + + {getEntityIcon('Company')} + {group.owner.name} + {group.owner.address} + + } + className="glass-panel" + extra={} + > + { + const d = item.data as EntityData; + const title = d.device_name || d.rn_kkt || d.uuid; + const subtitle = d.ip || d.serial_number || ''; + const statusRaw = d.operational_status || d.health_status; + + // getStatusColor теперь возвращает корректный Union Type для Badge + const badgeStatus = getStatusColor(statusRaw); + + return ( + + + {getEntityIcon(item.entity_type)} +
+ } + title={ + + {title} + + + } + description={ + + {getEntityLabel(item.entity_type)} + {subtitle} + + } + /> + + + ); + }} + /> + + ))} +
+ )} + + ); +}; + +export default SearchPage; \ No newline at end of file diff --git a/src/pages/TasksPage.tsx b/src/pages/TasksPage.tsx new file mode 100644 index 0000000..047225f --- /dev/null +++ b/src/pages/TasksPage.tsx @@ -0,0 +1,109 @@ +import React, { useState } from 'react'; +import { Table, Card, Select, Button, Space, Typography } from 'antd'; +import { useQuery } from '@tanstack/react-query'; +import { tasksApi } from '@/api/tasks'; +import { TaskDTO, TaskStatus } from '@/types/api'; +import { getEntityIcon } from '@/utils/mappers'; +import TaskStatusTag from '@/components/common/TaskStatusTag'; +import TaskResolutionModal from '@/features/tasks/TaskResolutionModal'; +import { ReloadOutlined } from '@ant-design/icons'; + +const { Option } = Select; +const { Title } = Typography; + +// Тип для фильтра, включающий 'all' +type FilterStatus = TaskStatus | 'all'; + +const TasksPage: React.FC = () => { + const [statusFilter, setStatusFilter] = useState('new'); + const [page, setPage] = useState(1); + const [selectedTask, setSelectedTask] = useState(null); + + const { data, isLoading, isFetching, refetch } = useQuery({ + queryKey: ['tasks', statusFilter, page], + queryFn: () => tasksApi.getTasks(statusFilter === 'all' ? undefined : statusFilter, page), + }); + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + width: 80, + }, + { + title: 'Тип', + dataIndex: 'task_type', + render: (type: string) => {getEntityIcon('default')} {type}, + }, + { + title: 'Сущность', + dataIndex: 'entity_type', + }, + { + title: 'Статус', + dataIndex: 'status', + render: (status: TaskStatus) => , + }, + { + title: 'Создана', + dataIndex: 'created_at', + render: (date: string) => new Date(date).toLocaleString(), + }, + { + title: 'Действие', + key: 'action', + // Используем unknown для первого аргумента, так как он не используется + render: (_: unknown, record: TaskDTO) => ( + + ), + }, + ]; + + return ( +
+
+ Задачи оператора + +
+ + + setPage(p), + }} + /> + + + setSelectedTask(null)} + /> + + ); +}; + +export default TasksPage; \ No newline at end of file diff --git a/src/types/api.ts b/src/types/api.ts new file mode 100644 index 0000000..0d7b64f --- /dev/null +++ b/src/types/api.ts @@ -0,0 +1,81 @@ +// Общий конверт ответа API +export interface ApiResponse { + status: 'success' | 'error'; + data: T; + meta?: PaginationMeta; + error?: { + error: string; + }; +} + +export interface PaginationMeta { + total: number; + limit: number; + offset: number; + has_next: boolean; + has_prev: boolean; +} + +// --- Common Entities --- +export interface CompanyOwner { + uuid: string; + external_uuid: string | null; + name: string; + address: string; + active_contract: boolean; + parent_info?: { uuid: string; name: string }; +} + +// Тип для статуса бейджа Ant Design +export type AntBadgeStatus = 'success' | 'processing' | 'error' | 'default' | 'warning'; + +export interface EntityData { + uuid: string; + device_name?: string; + ip?: string; // Для серверов + rn_kkt?: string; // Для ФР + serial_number?: string; + operational_status?: 'active' | 'offline' | 'unknown'; + health_status?: 'ok' | 'attention_required' | 'locked'; + // Используем unknown вместо any для безопасной работы с динамическими полями + [key: string]: unknown; +} + +// --- Search DTO --- +export interface SearchFoundEntity { + entity_type: 'Server' | 'Workstation' | 'FiscalRegister'; + data: EntityData; +} + +export interface SearchResultGroup { + owner: CompanyOwner; + found_entities: SearchFoundEntity[]; +} + +export interface SearchResponseData { + search_results: SearchResultGroup[]; +} + +// --- Tasks DTO --- +export type TaskStatus = 'new' | 'resolved' | 'rejected' | 'pending_sd_action' | 'sd_error'; +export type TaskType = 'add_equipment' | 'conflict' | 'offline_alert'; + +export interface TaskDTO { + id: number; + task_type: TaskType; + entity_type: string; + status: TaskStatus; + created_at: string; + // Детали могут быть любой структуры, но мы знаем, что это объект + details: Record; +} + +export interface TaskResolutionPayload { + status: 'resolved' | 'rejected'; + comment?: string; + resolution_payload?: { + action: string; + new_owner_id?: string; + [key: string]: unknown; + }; +} \ No newline at end of file diff --git a/src/utils/mappers.tsx b/src/utils/mappers.tsx new file mode 100644 index 0000000..31f8749 --- /dev/null +++ b/src/utils/mappers.tsx @@ -0,0 +1,46 @@ +import React from 'react'; +import { + DatabaseOutlined, + DesktopOutlined, + CalculatorOutlined, + BankOutlined, + QuestionCircleOutlined, +} from '@ant-design/icons'; +import { AntBadgeStatus } from '@/types/api'; + +export const getEntityIcon = (type: string): React.ReactNode => { + switch (type) { + case 'Server': return ; + case 'Workstation': return ; + case 'FiscalRegister': return ; + case 'Company': return ; + default: return ; + } +}; + +export const getEntityLabel = (type: string): string => { + const map: Record = { + Server: 'Сервер', + Workstation: 'Рабочая станция', + FiscalRegister: 'Фискальный регистратор', + }; + return map[type] || type; +}; + +export const getStatusColor = (status?: unknown): AntBadgeStatus => { + // Приведение строки к валидному статусу Badge + const s = String(status); + switch (s) { + case 'active': + case 'ok': + return 'success'; + case 'offline': + case 'locked': + return 'error'; + case 'attention_required': + case 'unknown': + return 'warning'; + default: + return 'default'; + } +}; \ No newline at end of file