14.12.25 - Этап 1 на фронте закончили. Поиск и задачи начались

This commit is contained in:
2025-12-14 00:14:05 +03:00
parent be9fdad7d0
commit cd497c4b26
12 changed files with 610 additions and 80 deletions

View File

@@ -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
View 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
View 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;
},
};

View 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;

View 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;

View File

@@ -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">

View 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
View 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
View 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
View 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
View 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';
}
};