14.12.25 - Этап 1 на фронте закончили. Поиск и задачи начались
This commit is contained in:
73
README.md
73
README.md
@@ -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...
|
|
||||||
},
|
|
||||||
},
|
|
||||||
])
|
|
||||||
```
|
|
||||||
@@ -7,6 +7,8 @@ import ruRU from 'antd/locale/ru_RU';
|
|||||||
import MainLayout from '@/components/layout/MainLayout';
|
import MainLayout from '@/components/layout/MainLayout';
|
||||||
import LoginPage from '@/pages/auth/LoginPage';
|
import LoginPage from '@/pages/auth/LoginPage';
|
||||||
import Dashboard from '@/pages/Dashboard';
|
import Dashboard from '@/pages/Dashboard';
|
||||||
|
import SearchPage from '@/pages/SearchPage';
|
||||||
|
import TasksPage from '@/pages/TasksPage';
|
||||||
|
|
||||||
import { useUiStore } from '@/store/uiStore';
|
import { useUiStore } from '@/store/uiStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
@@ -58,7 +60,8 @@ const App: React.FC = () => {
|
|||||||
}>
|
}>
|
||||||
{/* Вложенные роуты внутри MainLayout */}
|
{/* Вложенные роуты внутри MainLayout */}
|
||||||
<Route index element={<Dashboard />} />
|
<Route index element={<Dashboard />} />
|
||||||
<Route path="tasks" element={<div>Задачи</div>} />
|
<Route path="search" element={<SearchPage />} />
|
||||||
|
<Route path="tasks" element={<TasksPage />} />
|
||||||
<Route path="tickets" element={<div>Тикеты</div>} />
|
<Route path="tickets" element={<div>Тикеты</div>} />
|
||||||
<Route path="companies" element={<div>Компании</div>} />
|
<Route path="companies" element={<div>Компании</div>} />
|
||||||
<Route path="servers" element={<div>Серверы</div>} />
|
<Route path="servers" element={<div>Серверы</div>} />
|
||||||
|
|||||||
11
src/api/search.ts
Normal file
11
src/api/search.ts
Normal file
@@ -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<ApiResponse<SearchResponseData>>('/search', {
|
||||||
|
params: { term, limit },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
24
src/api/tasks.ts
Normal file
24
src/api/tasks.ts
Normal file
@@ -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<ApiResponse<TaskDTO[]>>('/tasks', {
|
||||||
|
params: { status, limit, offset },
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
resolveTask: async (id: number, payload: TaskResolutionPayload) => {
|
||||||
|
const response = await apiClient.post<ApiResponse<void>>(`/tasks/${id}/resolve`, payload);
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
|
||||||
|
createEntityInSd: async (id: number, entityType: string) => {
|
||||||
|
const response = await apiClient.post<ApiResponse<void>>(`/tasks/${id}/create-entity-in-sd`, {
|
||||||
|
entity_type: entityType,
|
||||||
|
});
|
||||||
|
return response.data;
|
||||||
|
},
|
||||||
|
};
|
||||||
28
src/components/common/HeaderSearch.tsx
Normal file
28
src/components/common/HeaderSearch.tsx
Normal file
@@ -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 (
|
||||||
|
<Input.Search
|
||||||
|
placeholder="Поиск по IP, Serial, Name..."
|
||||||
|
allowClear
|
||||||
|
defaultValue={currentTerm}
|
||||||
|
onSearch={onSearch}
|
||||||
|
style={{ width: 400 }}
|
||||||
|
className="header-search-input"
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default HeaderSearch;
|
||||||
51
src/components/common/TaskStatusTag.tsx
Normal file
51
src/components/common/TaskStatusTag.tsx
Normal file
@@ -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<Props> = ({ status }) => {
|
||||||
|
let color = 'default';
|
||||||
|
let label = status;
|
||||||
|
let icon = null;
|
||||||
|
|
||||||
|
switch (status) {
|
||||||
|
case 'new':
|
||||||
|
color = 'blue';
|
||||||
|
label = 'Новая';
|
||||||
|
icon = <ExclamationCircleOutlined />;
|
||||||
|
break;
|
||||||
|
case 'resolved':
|
||||||
|
color = 'green';
|
||||||
|
label = 'Решена';
|
||||||
|
icon = <CheckCircleOutlined />;
|
||||||
|
break;
|
||||||
|
case 'rejected':
|
||||||
|
color = 'red';
|
||||||
|
label = 'Отклонена';
|
||||||
|
icon = <CloseCircleOutlined />;
|
||||||
|
break;
|
||||||
|
case 'pending_sd_action':
|
||||||
|
color = 'orange';
|
||||||
|
label = 'В обработке SD';
|
||||||
|
icon = <SyncOutlined spin />;
|
||||||
|
break;
|
||||||
|
case 'sd_error':
|
||||||
|
color = 'volcano';
|
||||||
|
label = 'Ошибка SD';
|
||||||
|
icon = <CloseCircleOutlined />;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return <Tag color={color} icon={icon}>{label}</Tag>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskStatusTag;
|
||||||
@@ -15,6 +15,7 @@ import {
|
|||||||
SunOutlined,
|
SunOutlined,
|
||||||
MoonOutlined
|
MoonOutlined
|
||||||
} from '@ant-design/icons';
|
} from '@ant-design/icons';
|
||||||
|
import HeaderSearch from '@/components/common/HeaderSearch';
|
||||||
import { useUiStore } from '@/store/uiStore';
|
import { useUiStore } from '@/store/uiStore';
|
||||||
import { useAuthStore } from '@/store/authStore';
|
import { useAuthStore } from '@/store/authStore';
|
||||||
|
|
||||||
@@ -114,11 +115,11 @@ const MainLayout: React.FC = () => {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Header style={{
|
<Header style={{
|
||||||
padding: '0 24px',
|
padding: '0 24px',
|
||||||
background: token.colorBgContainer, // Прозрачность задана в теме
|
background: token.colorBgContainer,
|
||||||
backdropFilter: 'blur(10px)',
|
backdropFilter: 'blur(10px)',
|
||||||
display: 'flex',
|
display: 'flex',
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
justifyContent: 'space-between',
|
justifyContent: 'space-between', // Важно: разносим элементы
|
||||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||||
position: 'sticky',
|
position: 'sticky',
|
||||||
top: 0,
|
top: 0,
|
||||||
@@ -131,10 +132,10 @@ const MainLayout: React.FC = () => {
|
|||||||
onClick={() => setCollapsed(!collapsed)}
|
onClick={() => setCollapsed(!collapsed)}
|
||||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||||
/>
|
/>
|
||||||
<Text strong style={{ fontSize: 18, marginLeft: 16 }}>
|
</div>
|
||||||
{/* Заголовок страницы можно сделать динамическим позже */}
|
{/* ЦЕНТР: Глобальный поиск */}
|
||||||
Dashboard
|
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
||||||
</Text>
|
<HeaderSearch />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Space size="middle">
|
<Space size="middle">
|
||||||
|
|||||||
145
src/features/tasks/TaskResolutionModal.tsx
Normal file
145
src/features/tasks/TaskResolutionModal.tsx
Normal file
@@ -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<Props> = ({ 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 (
|
||||||
|
<Descriptions bordered column={1} size="small" style={{ marginTop: 16 }}>
|
||||||
|
{Object.entries(task.details).map(([key, value]) => {
|
||||||
|
// Если значение - объект, рендерим его как JSON string (упрощенно)
|
||||||
|
const displayValue = typeof value === 'object' ? JSON.stringify(value, null, 2) : String(value);
|
||||||
|
return (
|
||||||
|
<Descriptions.Item key={key} label={key}>
|
||||||
|
<pre style={{ margin: 0, whiteSpace: 'pre-wrap', maxHeight: 200, overflow: 'auto' }}>
|
||||||
|
{displayValue}
|
||||||
|
</pre>
|
||||||
|
</Descriptions.Item>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Descriptions>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Modal
|
||||||
|
title={`Задача #${task.id} (${task.task_type})`}
|
||||||
|
open={visible}
|
||||||
|
onCancel={onClose}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
className="glass-panel"
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 16 }}>
|
||||||
|
<Space>
|
||||||
|
<TaskStatusTag status={task.status} />
|
||||||
|
<span style={{ color: '#8c8c8c' }}>{new Date(task.created_at).toLocaleString()}</span>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{task.status === 'new' && (
|
||||||
|
<Alert message="Требуется решение оператора" type="info" showIcon style={{ marginBottom: 16 }} />
|
||||||
|
)}
|
||||||
|
|
||||||
|
{renderDetails()}
|
||||||
|
|
||||||
|
<div style={{ marginTop: 24 }}>
|
||||||
|
<Input.TextArea
|
||||||
|
placeholder="Комментарий к решению..."
|
||||||
|
rows={3}
|
||||||
|
value={comment}
|
||||||
|
onChange={e => setComment(e.target.value)}
|
||||||
|
style={{ marginBottom: 16 }}
|
||||||
|
disabled={task.status !== 'new'}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{task.status === 'new' && (
|
||||||
|
<Space>
|
||||||
|
<Button
|
||||||
|
type="primary"
|
||||||
|
onClick={handleResolve}
|
||||||
|
loading={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
Подтвердить / Решить
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
danger
|
||||||
|
onClick={handleReject}
|
||||||
|
loading={resolveMutation.isPending}
|
||||||
|
>
|
||||||
|
Отклонить
|
||||||
|
</Button>
|
||||||
|
{/* Спец кнопка для Add Equipment, если еще не отправлено в SD */}
|
||||||
|
{task.task_type === 'add_equipment' && (
|
||||||
|
<Button
|
||||||
|
type="dashed"
|
||||||
|
onClick={() => createSdMutation.mutate()}
|
||||||
|
loading={createSdMutation.isPending}
|
||||||
|
>
|
||||||
|
Создать в ServiceDesk
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Modal>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TaskResolutionModal;
|
||||||
104
src/pages/SearchPage.tsx
Normal file
104
src/pages/SearchPage.tsx
Normal file
@@ -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 (
|
||||||
|
<div style={{ textAlign: 'center', marginTop: 100 }}>
|
||||||
|
<Title level={3}>Введите запрос для поиска</Title>
|
||||||
|
<Text type="secondary">IP адрес, серийный номер, ИНН или название</Text>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isLoading) return <div style={{ textAlign: 'center', padding: 50 }}><Spin size="large" /></div>;
|
||||||
|
if (isError) return <Text type="danger">Ошибка при выполнении поиска</Text>;
|
||||||
|
|
||||||
|
const results = data?.data?.search_results || [];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
<Title level={4}>Результаты поиска: "{term}"</Title>
|
||||||
|
|
||||||
|
{results.length === 0 ? (
|
||||||
|
<Empty description="Ничего не найдено" />
|
||||||
|
) : (
|
||||||
|
<Space direction="vertical" style={{ width: '100%' }} size="large">
|
||||||
|
{results.map((group) => (
|
||||||
|
<Card
|
||||||
|
key={group.owner.uuid}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
{getEntityIcon('Company')}
|
||||||
|
<Text strong>{group.owner.name}</Text>
|
||||||
|
<Text type="secondary" style={{ fontSize: 12 }}>{group.owner.address}</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
className="glass-panel"
|
||||||
|
extra={<Button type="link">Перейти к компании <ArrowRightOutlined /></Button>}
|
||||||
|
>
|
||||||
|
<List
|
||||||
|
itemLayout="horizontal"
|
||||||
|
dataSource={group.found_entities}
|
||||||
|
renderItem={(item: SearchFoundEntity) => {
|
||||||
|
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 (
|
||||||
|
<List.Item>
|
||||||
|
<List.Item.Meta
|
||||||
|
avatar={
|
||||||
|
<div style={{ fontSize: 24, color: '#1890ff' }}>
|
||||||
|
{getEntityIcon(item.entity_type)}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
title={
|
||||||
|
<Space>
|
||||||
|
<Text strong>{title}</Text>
|
||||||
|
<Badge status={badgeStatus} text={statusRaw || 'unknown'} />
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
description={
|
||||||
|
<Space split="|">
|
||||||
|
<Text type="secondary">{getEntityLabel(item.entity_type)}</Text>
|
||||||
|
<Text>{subtitle}</Text>
|
||||||
|
</Space>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<Button size="small">Детали</Button>
|
||||||
|
</List.Item>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
))}
|
||||||
|
</Space>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default SearchPage;
|
||||||
109
src/pages/TasksPage.tsx
Normal file
109
src/pages/TasksPage.tsx
Normal file
@@ -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<FilterStatus>('new');
|
||||||
|
const [page, setPage] = useState(1);
|
||||||
|
const [selectedTask, setSelectedTask] = useState<TaskDTO | null>(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) => <Space>{getEntityIcon('default')} {type}</Space>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Сущность',
|
||||||
|
dataIndex: 'entity_type',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Статус',
|
||||||
|
dataIndex: 'status',
|
||||||
|
render: (status: TaskStatus) => <TaskStatusTag status={status} />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Создана',
|
||||||
|
dataIndex: 'created_at',
|
||||||
|
render: (date: string) => new Date(date).toLocaleString(),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Действие',
|
||||||
|
key: 'action',
|
||||||
|
// Используем unknown для первого аргумента, так как он не используется
|
||||||
|
render: (_: unknown, record: TaskDTO) => (
|
||||||
|
<Button size="small" onClick={() => setSelectedTask(record)}>
|
||||||
|
Открыть
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div style={{ padding: 0 }}>
|
||||||
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 16 }}>
|
||||||
|
<Title level={3} style={{ margin: 0 }}>Задачи оператора</Title>
|
||||||
|
<Space>
|
||||||
|
<Button icon={<ReloadOutlined />} onClick={() => refetch()} loading={isFetching} />
|
||||||
|
{/* Явно указываем Generic для Select, чтобы val имел правильный тип */}
|
||||||
|
<Select<FilterStatus>
|
||||||
|
defaultValue="new"
|
||||||
|
style={{ width: 200 }}
|
||||||
|
onChange={(val) => setStatusFilter(val)}
|
||||||
|
>
|
||||||
|
<Option value="all">Все задачи</Option>
|
||||||
|
<Option value="new">Новые</Option>
|
||||||
|
<Option value="resolved">Решенные</Option>
|
||||||
|
<Option value="rejected">Отклоненные</Option>
|
||||||
|
<Option value="pending_sd_action">В обработке SD</Option>
|
||||||
|
</Select>
|
||||||
|
</Space>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Card className="glass-panel" styles={{ body: { padding: 0 } }}>
|
||||||
|
<Table
|
||||||
|
dataSource={data?.data}
|
||||||
|
columns={columns}
|
||||||
|
rowKey="id"
|
||||||
|
loading={isLoading}
|
||||||
|
pagination={{
|
||||||
|
current: page,
|
||||||
|
total: data?.meta?.total || 0,
|
||||||
|
pageSize: data?.meta?.limit || 50,
|
||||||
|
onChange: (p) => setPage(p),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<TaskResolutionModal
|
||||||
|
visible={!!selectedTask}
|
||||||
|
task={selectedTask}
|
||||||
|
onClose={() => setSelectedTask(null)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default TasksPage;
|
||||||
81
src/types/api.ts
Normal file
81
src/types/api.ts
Normal file
@@ -0,0 +1,81 @@
|
|||||||
|
// Общий конверт ответа API
|
||||||
|
export interface ApiResponse<T> {
|
||||||
|
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<string, unknown>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface TaskResolutionPayload {
|
||||||
|
status: 'resolved' | 'rejected';
|
||||||
|
comment?: string;
|
||||||
|
resolution_payload?: {
|
||||||
|
action: string;
|
||||||
|
new_owner_id?: string;
|
||||||
|
[key: string]: unknown;
|
||||||
|
};
|
||||||
|
}
|
||||||
46
src/utils/mappers.tsx
Normal file
46
src/utils/mappers.tsx
Normal file
@@ -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 <DatabaseOutlined />;
|
||||||
|
case 'Workstation': return <DesktopOutlined />;
|
||||||
|
case 'FiscalRegister': return <CalculatorOutlined />;
|
||||||
|
case 'Company': return <BankOutlined />;
|
||||||
|
default: return <QuestionCircleOutlined />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
export const getEntityLabel = (type: string): string => {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
};
|
||||||
Reference in New Issue
Block a user