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 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 */}
|
||||
<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="companies" 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,
|
||||
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 = () => {
|
||||
<Layout>
|
||||
<Header style={{
|
||||
padding: '0 24px',
|
||||
background: token.colorBgContainer, // Прозрачность задана в теме
|
||||
background: token.colorBgContainer,
|
||||
backdropFilter: 'blur(10px)',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
justifyContent: 'space-between', // Важно: разносим элементы
|
||||
borderBottom: `1px solid ${token.colorBorderSecondary}`,
|
||||
position: 'sticky',
|
||||
top: 0,
|
||||
@@ -131,10 +132,10 @@ const MainLayout: React.FC = () => {
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
style={{ fontSize: '16px', width: 64, height: 64 }}
|
||||
/>
|
||||
<Text strong style={{ fontSize: 18, marginLeft: 16 }}>
|
||||
{/* Заголовок страницы можно сделать динамическим позже */}
|
||||
Dashboard
|
||||
</Text>
|
||||
</div>
|
||||
{/* ЦЕНТР: Глобальный поиск */}
|
||||
<div style={{ flex: 1, display: 'flex', justifyContent: 'center' }}>
|
||||
<HeaderSearch />
|
||||
</div>
|
||||
|
||||
<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