14.12.25 - Этап 2 на фронте. Написаны отдельные компоненты для оборудки, страница компании, немного поправил светлую тему
This commit is contained in:
26
src/App.tsx
26
src/App.tsx
@@ -9,6 +9,7 @@ import LoginPage from '@/pages/auth/LoginPage';
|
||||
import Dashboard from '@/pages/Dashboard';
|
||||
import SearchPage from '@/pages/SearchPage';
|
||||
import TasksPage from '@/pages/TasksPage';
|
||||
import CompanyPage from '@/pages/companies/CompanyPage';
|
||||
|
||||
import { useUiStore } from '@/store/uiStore';
|
||||
import { useAuthStore } from '@/store/authStore';
|
||||
@@ -30,16 +31,16 @@ const ProtectedRoute = ({ children }: { children: React.ReactNode }) => {
|
||||
if (!isAuthenticated) {
|
||||
return <Navigate to="/login" replace />;
|
||||
}
|
||||
// ReactNode валиден для возврата
|
||||
return <>{children}</>;
|
||||
};
|
||||
|
||||
const App: React.FC = () => {
|
||||
const themeMode = useUiStore((state) => state.themeMode);
|
||||
|
||||
// Применение темы к body для смены общего фона (за пределами React компонентов)
|
||||
// Обновляем логику фона: синхронизируем с themeConfig
|
||||
useEffect(() => {
|
||||
const colorBgLayout = themeMode === 'dark' ? '#0a111b' : '#f0f5ff';
|
||||
// #000000 для темной, #f0f2f5 для светлой
|
||||
const colorBgLayout = themeMode === 'dark' ? '#000000' : '#f0f2f5';
|
||||
document.body.style.backgroundColor = colorBgLayout;
|
||||
}, [themeMode]);
|
||||
|
||||
@@ -58,15 +59,24 @@ const App: React.FC = () => {
|
||||
<MainLayout />
|
||||
</ProtectedRoute>
|
||||
}>
|
||||
{/* Вложенные роуты внутри MainLayout */}
|
||||
<Route index element={<Dashboard />} />
|
||||
<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>} />
|
||||
<Route path="workstations" element={<div>РС</div>} />
|
||||
<Route path="fiscals" element={<div>ФР</div>} />
|
||||
|
||||
<Route path="companies" element={<div>Список компаний</div>} />
|
||||
<Route path="companies/:id" element={<CompanyPage />} />
|
||||
|
||||
{/* Роуты для оборудования */}
|
||||
<Route path="servers" element={<div>Список серверов</div>} />
|
||||
<Route path="servers/:id" element={<div>Детали сервера (в разработке)</div>} />
|
||||
|
||||
<Route path="workstations" element={<div>Список РС</div>} />
|
||||
<Route path="workstations/:id" element={<div>Детали РС (в разработке)</div>} />
|
||||
|
||||
<Route path="fiscals" element={<div>Список ФР</div>} />
|
||||
<Route path="fiscals/:id" element={<div>Детали ФР (в разработке)</div>} />
|
||||
|
||||
<Route path="admin" element={<div>Админка</div>} />
|
||||
</Route>
|
||||
|
||||
|
||||
17
src/api/companies.ts
Normal file
17
src/api/companies.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import apiClient from './axios';
|
||||
import { ApiResponse, CompanyModel, InfrastructureItem } from '@/types/api';
|
||||
|
||||
export const companiesApi = {
|
||||
// Получение профиля компании
|
||||
getCompany: async (id: string) => {
|
||||
// В URL передаем ID компании
|
||||
const response = await apiClient.get<ApiResponse<CompanyModel>>(`/companies/${id}`);
|
||||
return response.data;
|
||||
},
|
||||
|
||||
// Получение инфраструктуры (CMDB)
|
||||
getInfrastructure: async (id: string) => {
|
||||
const response = await apiClient.get<ApiResponse<InfrastructureItem[]>>(`/companies/${id}/infrastructure`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
11
src/api/equipment.ts
Normal file
11
src/api/equipment.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
import apiClient from './axios';
|
||||
import { ApiResponse } from '@/types/api';
|
||||
|
||||
export const equipmentApi = {
|
||||
// Принудительный опрос сервера
|
||||
pollServer: async (uuid: string) => {
|
||||
// Предполагаем, что бэкенд поддерживает этот эндпоинт
|
||||
const response = await apiClient.post<ApiResponse<void>>(`/servers/${uuid}/poll`);
|
||||
return response.data;
|
||||
},
|
||||
};
|
||||
102
src/components/entities/FiscalCard.tsx
Normal file
102
src/components/entities/FiscalCard.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
import React from 'react';
|
||||
import { Card, Badge, Space, Typography, Tag, Tooltip } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { FiscalEntity } from '@/types/api';
|
||||
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||
import { formatRnm } from '@/utils/formatters';
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
interface Props {
|
||||
data: FiscalEntity;
|
||||
}
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const FiscalCard: React.FC<Props> = ({ data }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const renderFnInfo = (dateStr?: string) => {
|
||||
if (!dateStr) return <Tag>Нет ФН</Tag>;
|
||||
const expireDate = dayjs(dateStr);
|
||||
const daysLeft = expireDate.diff(dayjs(), 'day');
|
||||
|
||||
let color = 'green';
|
||||
let label = 'ФН OK';
|
||||
|
||||
if (daysLeft < 0) {
|
||||
color = 'red';
|
||||
label = 'ФН Истек';
|
||||
} else if (daysLeft < 30) {
|
||||
color = 'orange';
|
||||
label = `ФН: ${daysLeft} дн.`;
|
||||
}
|
||||
|
||||
return (
|
||||
<Space size={4}>
|
||||
<Tag color={color} style={{ marginRight: 0 }}>{label}</Tag>
|
||||
<Text type="secondary" style={{ fontSize: 11 }}>
|
||||
(до {expireDate.format('DD.MM.YYYY')})
|
||||
</Text>
|
||||
</Space>
|
||||
);
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/fiscals/${data.uuid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="glass-panel"
|
||||
hoverable
|
||||
onClick={handleCardClick}
|
||||
title={
|
||||
<Space>
|
||||
{getEntityIcon('FiscalRegister')}
|
||||
<Text strong>{data.model_kkt || 'ККТ'}</Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={`Статус здоровья: ${data.health_status}`}>
|
||||
<Badge status={getStatusColor(data.health_status)} />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<Text strong style={{ display: 'block' }}>{data.organization_name}</Text>
|
||||
{data.inn && <Text type="secondary" style={{ fontSize: 12, marginRight: 8 }}>ИНН: {data.inn}</Text>}
|
||||
{/* address теперь типизирован как string | undefined, проверка безопасна */}
|
||||
{data.address && <Text type="secondary" style={{ fontSize: 12 }}>| {data.address}</Text>}
|
||||
</div>
|
||||
|
||||
<Space direction="vertical" size={4} style={{ width: '100%' }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">РНМ:</Text>
|
||||
<Paragraph copyable={{ text: data.rn_kkt }} style={{ margin: 0, fontFamily: 'monospace' }}>
|
||||
{formatRnm(data.rn_kkt)}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Text type="secondary">SN:</Text>
|
||||
<Paragraph copyable={{ text: data.serial_number }} style={{ margin: 0, fontSize: 12 }}>
|
||||
{data.serial_number}
|
||||
</Paragraph>
|
||||
</div>
|
||||
|
||||
<div style={{ marginTop: 4 }}>
|
||||
{renderFnInfo(data.fn_expire_date)}
|
||||
</div>
|
||||
|
||||
{(data.driver_version || data.fr_firmware) && (
|
||||
<div style={{ marginTop: 4, fontSize: 11, color: '#8c8c8c', borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 4 }}>
|
||||
FW: {data.fr_firmware} {data.driver_version ? `| Drv: ${data.driver_version}` : ''}
|
||||
</div>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default FiscalCard;
|
||||
208
src/components/entities/ServerCard.tsx
Normal file
208
src/components/entities/ServerCard.tsx
Normal file
@@ -0,0 +1,208 @@
|
||||
import React from 'react';
|
||||
import { Card, Badge, Button, Space, Typography, Tooltip, message, Tag } from 'antd';
|
||||
import { LinkOutlined, SyncOutlined, GlobalOutlined } from '@ant-design/icons';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||
import { ServerEntity } from '@/types/api';
|
||||
import { equipmentApi } from '@/api/equipment';
|
||||
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||
import { cleanWebUrl, formatServerEdition, formatDate } from '@/utils/formatters';
|
||||
|
||||
interface Props {
|
||||
data: ServerEntity;
|
||||
}
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const ServerCard: React.FC<Props> = ({ data }) => {
|
||||
const navigate = useNavigate();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
// Определяем, является ли сервер облачным/веб (iikoWeb, Syrve)
|
||||
const isCloud = (data.ip || '').toLowerCase().includes('iikoweb') ||
|
||||
(data.ip || '').toLowerCase().includes('syrve');
|
||||
|
||||
const isOnline = data.operational_status === 'active';
|
||||
|
||||
// Мутация для опроса
|
||||
const pollMutation = useMutation({
|
||||
mutationFn: () => equipmentApi.pollServer(data.uuid),
|
||||
onSuccess: () => {
|
||||
message.success('Запрос на опрос отправлен');
|
||||
queryClient.invalidateQueries({ predicate: (query) =>
|
||||
query.queryKey[0] === 'company' || query.queryKey[0] === 'search'
|
||||
});
|
||||
},
|
||||
onError: () => message.error('Не удалось выполнить опрос'),
|
||||
});
|
||||
|
||||
const handlePoll = (e: React.MouseEvent) => {
|
||||
e.stopPropagation(); // Чтобы не срабатывал клик по карточке
|
||||
pollMutation.mutate();
|
||||
};
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/servers/${data.uuid}`);
|
||||
};
|
||||
|
||||
// --- Рендер для iikoWeb / Cloud серверов ---
|
||||
if (isCloud) {
|
||||
const webUrl = data.ip ? cleanWebUrl(data.ip) : '';
|
||||
const fullUrl = `https://${webUrl}`;
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="glass-panel"
|
||||
hoverable
|
||||
onClick={handleCardClick}
|
||||
title={
|
||||
<Space>
|
||||
<GlobalOutlined style={{ color: '#1890ff' }} />
|
||||
<Text strong>{data.device_name || 'Cloud Server'}</Text>
|
||||
</Space>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={8} style={{ width: '100%' }}>
|
||||
{/* Row 1: Address Copy + Link Button */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<Paragraph copyable={{ text: webUrl }} style={{ margin: 0, maxWidth: 140 }} ellipsis>
|
||||
{webUrl}
|
||||
</Paragraph>
|
||||
<Button
|
||||
size="small"
|
||||
type="primary"
|
||||
ghost
|
||||
href={fullUrl}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
icon={<LinkOutlined />}
|
||||
>
|
||||
iikoWeb
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Row 2: Version + Type */}
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">Версия:</Text>
|
||||
<Text strong>{data.server_version || '-'} <Tag style={{ marginLeft: 4, marginRight: 0 }}>{formatServerEdition(data.server_edition) || 'Web'}</Tag></Text>
|
||||
</div>
|
||||
|
||||
{/* Row 3: UID */}
|
||||
{data.unique_id && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">UID:</Text>
|
||||
<Paragraph copyable={{ text: data.unique_id }} style={{ margin: 0 }}>
|
||||
{data.unique_id.substring(0, 15)}...
|
||||
</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Action: Poll Button with Last Polled Date */}
|
||||
<div style={{ marginTop: 8, display: 'flex', justifyContent: 'flex-end' }}>
|
||||
<Button
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={pollMutation.isPending} />}
|
||||
onClick={handlePoll}
|
||||
loading={pollMutation.isPending}
|
||||
>
|
||||
{pollMutation.isPending ? 'Опрос...' : formatDate(data.last_polled_at)}
|
||||
</Button>
|
||||
</div>
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
|
||||
// --- Рендер для обычных серверов (RMS) ---
|
||||
const renderAccessLink = (label: string, value?: string) => {
|
||||
if (!value) return null;
|
||||
return (
|
||||
<Tooltip title={`Копировать ID/Link для ${label}`}>
|
||||
<Paragraph copyable={{ text: value }} style={{ margin: 0 }}>
|
||||
<Text type="secondary">{label}:</Text> {value}
|
||||
</Paragraph>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="glass-panel"
|
||||
hoverable
|
||||
onClick={handleCardClick}
|
||||
title={
|
||||
<Space>
|
||||
{getEntityIcon('Server')}
|
||||
<Text strong>{data.device_name || data.server_name || 'Unknown Server'}</Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Space>
|
||||
<Tooltip title={`Network: ${data.operational_status}`}>
|
||||
<Badge status={isOnline ? 'success' : 'error'} />
|
||||
</Tooltip>
|
||||
<Tooltip title={`Health: ${data.health_status}`}>
|
||||
<Badge status={getStatusColor(data.health_status)} />
|
||||
</Tooltip>
|
||||
</Space>
|
||||
}
|
||||
actions={[
|
||||
<Button
|
||||
key="poll"
|
||||
type="text"
|
||||
size="small"
|
||||
icon={<SyncOutlined spin={pollMutation.isPending} />}
|
||||
onClick={handlePoll}
|
||||
style={{ width: '100%' }}
|
||||
>
|
||||
{pollMutation.isPending ? 'Опрос...' : 'Обновить статус'}
|
||||
</Button>
|
||||
]}
|
||||
>
|
||||
<div style={{ marginBottom: 12 }}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 4 }}>
|
||||
<Text type="secondary">IP:</Text>
|
||||
<Paragraph copyable={{ text: data.ip }} style={{ margin: 0 }}>
|
||||
<Text strong>{data.ip || 'No IP'}</Text>
|
||||
</Paragraph>
|
||||
</div>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text type="secondary">Версия:</Text>
|
||||
<Text>{data.server_version} {data.server_edition ? `(${formatServerEdition(data.server_edition)})` : ''}</Text>
|
||||
</div>
|
||||
{data.partners_link && (
|
||||
<div style={{ marginTop: 4, textAlign: 'right' }}>
|
||||
<Button
|
||||
type="link"
|
||||
size="small"
|
||||
href={data.partners_link}
|
||||
target="_blank"
|
||||
onClick={e => e.stopPropagation()}
|
||||
icon={<LinkOutlined />}
|
||||
style={{ paddingRight: 0 }}
|
||||
>
|
||||
Кабинет дилера
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ borderTop: '1px solid rgba(0,0,0,0.06)', paddingTop: 8 }}>
|
||||
<Space direction="vertical" size={0} style={{ width: '100%' }}>
|
||||
{renderAccessLink('AnyDesk', data.anydesk)}
|
||||
{renderAccessLink('TeamViewer', data.teamviewer)}
|
||||
{renderAccessLink('RDP', data.rdp)}
|
||||
{renderAccessLink('LM', data.litemanager)}
|
||||
|
||||
{!data.anydesk && !data.teamviewer && !data.rdp && !data.litemanager && (
|
||||
<Text type="secondary" italic>Нет данных для доступа</Text>
|
||||
)}
|
||||
</Space>
|
||||
</div>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default ServerCard;
|
||||
79
src/components/entities/WorkstationCard.tsx
Normal file
79
src/components/entities/WorkstationCard.tsx
Normal file
@@ -0,0 +1,79 @@
|
||||
import React from 'react';
|
||||
import { Card, Badge, Space, Typography, Tooltip } from 'antd';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { WorkstationEntity } from '@/types/api';
|
||||
import { getEntityIcon, getStatusColor } from '@/utils/mappers';
|
||||
|
||||
interface Props {
|
||||
data: WorkstationEntity;
|
||||
}
|
||||
|
||||
const { Text, Paragraph } = Typography;
|
||||
|
||||
const WorkstationCard: React.FC<Props> = ({ data }) => {
|
||||
const navigate = useNavigate();
|
||||
|
||||
const handleCardClick = () => {
|
||||
navigate(`/workstations/${data.uuid}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Card
|
||||
size="small"
|
||||
className="glass-panel"
|
||||
hoverable
|
||||
onClick={handleCardClick}
|
||||
title={
|
||||
<Space>
|
||||
{getEntityIcon('Workstation')}
|
||||
<Text strong>{data.device_name || 'Workstation'}</Text>
|
||||
</Space>
|
||||
}
|
||||
extra={
|
||||
<Tooltip title={`Health: ${data.health_status}`}>
|
||||
<Badge status={getStatusColor(data.health_status)} text={data.health_status} />
|
||||
</Tooltip>
|
||||
}
|
||||
>
|
||||
<Space direction="vertical" size={2} style={{ width: '100%' }}>
|
||||
{/* description теперь типизирован как string | undefined */}
|
||||
{data.description && (
|
||||
<Paragraph
|
||||
ellipsis={{ rows: 2, expandable: false }}
|
||||
type="secondary"
|
||||
style={{ fontSize: 12, marginBottom: 8 }}
|
||||
>
|
||||
{data.description}
|
||||
</Paragraph>
|
||||
)}
|
||||
|
||||
{data.anydesk && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text>AnyDesk:</Text>
|
||||
<Paragraph copyable={{ text: data.anydesk }} style={{ margin: 0 }}>{data.anydesk}</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.teamviewer && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text>TV:</Text>
|
||||
<Paragraph copyable={{ text: data.teamviewer }} style={{ margin: 0 }}>{data.teamviewer}</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{data.litemanager && (
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between' }}>
|
||||
<Text>LM:</Text>
|
||||
<Paragraph copyable={{ text: data.litemanager }} style={{ margin: 0 }}>{data.litemanager}</Paragraph>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!data.anydesk && !data.teamviewer && !data.litemanager && !data.description && (
|
||||
<Text type="secondary" italic>Нет доп. информации</Text>
|
||||
)}
|
||||
</Space>
|
||||
</Card>
|
||||
);
|
||||
};
|
||||
|
||||
export default WorkstationCard;
|
||||
@@ -9,20 +9,23 @@ body {
|
||||
transition: background-color 0.3s ease;
|
||||
}
|
||||
|
||||
/* Глобальный эффект стекла для компонентов AntD */
|
||||
/*
|
||||
Глобальный эффект стекла.
|
||||
Убрали жесткий белый border, который был невиден на белом фоне.
|
||||
Теперь полагаемся на настройки темы AntD для border-color.
|
||||
*/
|
||||
.glass-panel {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
border: 1px solid rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
/* Принудительно применяем эффект к карточкам и модалкам */
|
||||
/* Принудительно применяем эффект к контенту AntD */
|
||||
.ant-card, .ant-modal-content, .ant-drawer-content {
|
||||
backdrop-filter: blur(12px);
|
||||
-webkit-backdrop-filter: blur(12px);
|
||||
}
|
||||
|
||||
/* Скроллбары для красоты */
|
||||
/* Скроллбары */
|
||||
::-webkit-scrollbar {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
@@ -31,6 +34,9 @@ body {
|
||||
background: transparent;
|
||||
}
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: #8c8c8c;
|
||||
background: #bfbfbf; /* Более нейтральный цвет скролла */
|
||||
border-radius: 4px;
|
||||
}
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: #999;
|
||||
}
|
||||
@@ -1,11 +1,14 @@
|
||||
import React from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useSearchParams, Link } from 'react-router-dom';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { Card, List, Typography, Spin, Empty, Badge, Button, Space } from 'antd';
|
||||
import { Card, Typography, Spin, Empty, Space, Row, Col, Button } from 'antd';
|
||||
import { searchApi } from '@/api/search';
|
||||
import { getEntityIcon, getEntityLabel, getStatusColor } from '@/utils/mappers';
|
||||
import { getEntityIcon } from '@/utils/mappers';
|
||||
import { ArrowRightOutlined } from '@ant-design/icons';
|
||||
import { SearchFoundEntity, EntityData } from '@/types/api';
|
||||
import { SearchFoundEntity, ServerEntity, WorkstationEntity, FiscalEntity } from '@/types/api';
|
||||
import ServerCard from '@/components/entities/ServerCard';
|
||||
import WorkstationCard from '@/components/entities/WorkstationCard';
|
||||
import FiscalCard from '@/components/entities/FiscalCard';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
@@ -34,6 +37,19 @@ const SearchPage: React.FC = () => {
|
||||
|
||||
const results = data?.data?.search_results || [];
|
||||
|
||||
const renderEntityCard = (item: SearchFoundEntity) => {
|
||||
switch (item.entity_type) {
|
||||
case 'Server':
|
||||
return <ServerCard data={item.data as ServerEntity} />;
|
||||
case 'Workstation':
|
||||
return <WorkstationCard data={item.data as WorkstationEntity} />;
|
||||
case 'FiscalRegister':
|
||||
return <FiscalCard data={item.data as FiscalEntity} />;
|
||||
default:
|
||||
return <div>Unknown entity type</div>;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<Title level={4}>Результаты поиска: "{term}"</Title>
|
||||
@@ -46,53 +62,30 @@ const SearchPage: React.FC = () => {
|
||||
<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>
|
||||
<Link to={`/companies/${group.owner.uuid}`} style={{ color: 'inherit' }}>
|
||||
<Space>
|
||||
{getEntityIcon('Company')}
|
||||
<Text strong style={{ cursor: 'pointer', textDecoration: 'underline' }}>
|
||||
{group.owner.name}
|
||||
</Text>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>{group.owner.address}</Text>
|
||||
</Space>
|
||||
</Link>
|
||||
}
|
||||
className="glass-panel"
|
||||
extra={<Button type="link">Перейти к компании <ArrowRightOutlined /></Button>}
|
||||
extra={
|
||||
<Link to={`/companies/${group.owner.uuid}`}>
|
||||
<Button type="link">Перейти к компании <ArrowRightOutlined /></Button>
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<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>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<Row gutter={[16, 16]}>
|
||||
{group.found_entities.map((item, idx) => (
|
||||
<Col key={`${item.entity_type}-${idx}`} xs={24} md={12} lg={8} xl={6}>
|
||||
{renderEntityCard(item)}
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</Card>
|
||||
))}
|
||||
</Space>
|
||||
|
||||
179
src/pages/companies/CompanyPage.tsx
Normal file
179
src/pages/companies/CompanyPage.tsx
Normal file
@@ -0,0 +1,179 @@
|
||||
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 { 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';
|
||||
|
||||
const { Title, Text } = Typography;
|
||||
|
||||
const CompanyPage: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
|
||||
// Запрос профиля
|
||||
const { data: companyRes, isLoading: loadingCompany } = useQuery({
|
||||
queryKey: ['company', id],
|
||||
queryFn: () => companiesApi.getCompany(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
// Запрос инфраструктуры
|
||||
const { data: infraRes, isLoading: loadingInfra } = useQuery({
|
||||
queryKey: ['company', id, 'infra'],
|
||||
queryFn: () => companiesApi.getInfrastructure(id!),
|
||||
enabled: !!id,
|
||||
});
|
||||
|
||||
const company = companyRes?.data;
|
||||
// Не используем '|| []' здесь, чтобы ссылка на данные была стабильной для useMemo
|
||||
const rawInfrastructure = infraRes?.data;
|
||||
|
||||
// Группировка инфраструктуры
|
||||
const groupedInfra = useMemo(() => {
|
||||
const servers: ServerEntity[] = [];
|
||||
const workstations: WorkstationEntity[] = [];
|
||||
const fiscals: FiscalEntity[] = [];
|
||||
|
||||
// Безопасно обрабатываем undefined внутри useMemo
|
||||
const list = rawInfrastructure || [];
|
||||
|
||||
list.forEach(item => {
|
||||
if (item.entity_type === 'Server') servers.push(item.data as ServerEntity);
|
||||
else if (item.entity_type === 'Workstation') workstations.push(item.data as WorkstationEntity);
|
||||
else if (item.entity_type === 'FiscalRegister') fiscals.push(item.data as FiscalEntity);
|
||||
});
|
||||
|
||||
return { servers, workstations, fiscals };
|
||||
}, [rawInfrastructure]);
|
||||
|
||||
if (loadingCompany) return <div style={{ padding: 50, textAlign: 'center' }}><Spin size="large" /></div>;
|
||||
if (!company) return <Empty description="Компания не найдена" />;
|
||||
|
||||
const items = [
|
||||
{
|
||||
key: 'infrastructure',
|
||||
label: 'Инфраструктура',
|
||||
children: (
|
||||
<div style={{ marginTop: 16 }}>
|
||||
{loadingInfra ? <Spin /> : (
|
||||
<>
|
||||
{/* Секция Серверов */}
|
||||
{groupedInfra.servers.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5}>Серверы ({groupedInfra.servers.length})</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{groupedInfra.servers.map(srv => (
|
||||
<Col key={srv.uuid} xs={24} md={12} lg={8} xl={6}>
|
||||
<ServerCard data={srv} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Секция Касс */}
|
||||
{groupedInfra.fiscals.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5}>Фискальные регистраторы ({groupedInfra.fiscals.length})</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{groupedInfra.fiscals.map(fr => (
|
||||
<Col key={fr.uuid} xs={24} md={12} lg={8} xl={6}>
|
||||
<FiscalCard data={fr} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Секция Рабочих станций */}
|
||||
{groupedInfra.workstations.length > 0 && (
|
||||
<div style={{ marginBottom: 24 }}>
|
||||
<Title level={5}>Рабочие станции ({groupedInfra.workstations.length})</Title>
|
||||
<Row gutter={[16, 16]}>
|
||||
{groupedInfra.workstations.map(ws => (
|
||||
<Col key={ws.uuid} xs={24} sm={12} md={8} lg={6}>
|
||||
<WorkstationCard data={ws} />
|
||||
</Col>
|
||||
))}
|
||||
</Row>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(!rawInfrastructure || rawInfrastructure.length === 0) && <Empty description="Оборудование не найдено" />}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
},
|
||||
{
|
||||
key: 'tickets',
|
||||
label: 'Тикеты',
|
||||
children: <Empty description="Раздел в разработке" style={{ marginTop: 20 }} />,
|
||||
},
|
||||
{
|
||||
key: 'contracts',
|
||||
label: 'Контракты',
|
||||
children: <Empty description="Раздел в разработке" style={{ marginTop: 20 }} />,
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* Header Профиля */}
|
||||
<Card className="glass-panel" style={{ marginBottom: 24 }}>
|
||||
<Link to="/companies" style={{ display: 'inline-flex', alignItems: 'center', marginBottom: 16, color: '#8c8c8c' }}>
|
||||
<ArrowLeftOutlined style={{ marginRight: 8 }} /> Назад к списку
|
||||
</Link>
|
||||
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center' }}>
|
||||
<div style={{
|
||||
width: 64, height: 64,
|
||||
background: '#e6f7ff',
|
||||
borderRadius: 8,
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
marginRight: 16,
|
||||
fontSize: 32, color: '#1890ff'
|
||||
}}>
|
||||
<BankOutlined />
|
||||
</div>
|
||||
<div>
|
||||
<Title level={3} style={{ margin: 0 }}>{company.Title}</Title>
|
||||
<Text type="secondary">{company.Address}</Text>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style={{ textAlign: 'right' }}>
|
||||
{company.ActiveContract ? (
|
||||
<Tag icon={<CheckCircleOutlined />} color="success">Контракт Активен</Tag>
|
||||
) : (
|
||||
<Tag icon={<CloseCircleOutlined />} color="error">Нет контракта</Tag>
|
||||
)}
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<Text type="secondary" style={{ fontSize: 12 }}>ID: {company.ID}</Text>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Descriptions size="small" style={{ marginTop: 24 }} column={2}>
|
||||
<Descriptions.Item label="Юр. название">{company.AdditionalName || '-'}</Descriptions.Item>
|
||||
<Descriptions.Item label="Родительская компания">
|
||||
{company.ParentID ? <Link to={`/companies/${company.ParentID}`}>{company.ParentID}</Link> : '-'}
|
||||
</Descriptions.Item>
|
||||
<Descriptions.Item label="Обновлено">
|
||||
{company.LastModifiedDate ? new Date(company.LastModifiedDate).toLocaleDateString() : '-'}
|
||||
</Descriptions.Item>
|
||||
</Descriptions>
|
||||
</Card>
|
||||
|
||||
{/* Основной контент */}
|
||||
<Tabs defaultActiveKey="infrastructure" items={items} />
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default CompanyPage;
|
||||
@@ -1,18 +1,19 @@
|
||||
// Исправленные импорты: разделяем значения и типы
|
||||
import { theme } from 'antd';
|
||||
import type { ThemeConfig } from 'antd';
|
||||
|
||||
// Цветовые палитры для тем
|
||||
// Цветовые палитры
|
||||
const lightColors = {
|
||||
primary: '#1890ff',
|
||||
bgContainer: 'rgba(230, 247, 255, 0.65)',
|
||||
bgLayout: '#f0f5ff',
|
||||
primary: '#1677ff', // Чуть более глубокий синий (AntD v5 default)
|
||||
bgContainer: 'rgba(255, 255, 255, 0.85)', // Почти белый, слегка прозрачный
|
||||
bgLayout: '#f0f2f5a9', // Нейтральный светло-серый, приятный глазу
|
||||
borderColor: '#d9d9d960', // Стандартный серый бордер
|
||||
};
|
||||
|
||||
const darkColors = {
|
||||
primary: '#177ddc',
|
||||
bgContainer: 'rgba(17, 29, 44, 0.65)',
|
||||
bgLayout: '#0a111b',
|
||||
bgContainer: 'rgba(20, 20, 20, 0.65)', // Темный, прозрачный
|
||||
bgLayout: '#000000', // Глубокий черный для контраста в OLED стиле
|
||||
borderColor: '#303030',
|
||||
};
|
||||
|
||||
export const getThemeConfig = (mode: 'light' | 'dark'): ThemeConfig => {
|
||||
@@ -20,29 +21,32 @@ export const getThemeConfig = (mode: 'light' | 'dark'): ThemeConfig => {
|
||||
const colors = isDark ? darkColors : lightColors;
|
||||
|
||||
return {
|
||||
// Теперь theme.darkAlgorithm и theme.defaultAlgorithm будут доступны
|
||||
algorithm: isDark ? theme.darkAlgorithm : theme.defaultAlgorithm,
|
||||
token: {
|
||||
colorPrimary: colors.primary,
|
||||
colorBgContainer: colors.bgContainer,
|
||||
colorBgLayout: colors.bgLayout,
|
||||
colorBorder: colors.borderColor, // Явный цвет границ
|
||||
borderRadius: 8,
|
||||
wireframe: false,
|
||||
// В светлой теме делаем тень мягче, но заметнее для отделения слоев
|
||||
boxShadow: isDark
|
||||
? '0 4px 12px rgba(0, 0, 0, 0.4)'
|
||||
: '0 4px 12px rgba(24, 144, 255, 0.15)',
|
||||
: '0 2px 8px rgba(0, 0, 0, 0.08)',
|
||||
},
|
||||
components: {
|
||||
Layout: {
|
||||
colorBgHeader: isDark ? 'rgba(20, 20, 20, 0.6)' : 'rgba(255, 255, 255, 0.6)',
|
||||
colorBgTrigger: colors.primary,
|
||||
// Хедер в светлой теме делаем чисто белым (или с легким блюром), чтобы отделить от серого фона
|
||||
colorBgHeader: isDark ? 'rgba(20, 20, 20, 0.6)' : 'rgba(255, 255, 255, 0.7)',
|
||||
},
|
||||
Menu: {
|
||||
colorBgContainer: 'transparent',
|
||||
},
|
||||
Card: {
|
||||
colorBgContainer: colors.bgContainer,
|
||||
lineWidth: 1,
|
||||
// Убираем лишние обводки в светлой теме, полагаемся на тень,
|
||||
// но оставляем тонкий бордер для четкости
|
||||
lineWidth: 1,
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
103
src/types/api.ts
103
src/types/api.ts
@@ -26,19 +26,105 @@ export interface CompanyOwner {
|
||||
parent_info?: { uuid: string; name: string };
|
||||
}
|
||||
|
||||
// Тип для статуса бейджа Ant Design
|
||||
export interface CompanyModel {
|
||||
ID: string;
|
||||
Title?: string;
|
||||
Address?: string;
|
||||
AdditionalName?: string;
|
||||
ActiveContract?: boolean;
|
||||
ParentID?: string;
|
||||
LastModifiedDate?: string;
|
||||
}
|
||||
|
||||
export type AntBadgeStatus = 'success' | 'processing' | 'error' | 'default' | 'warning';
|
||||
|
||||
export interface EntityData {
|
||||
// --- CMDB Entities (Rich DTOs) ---
|
||||
|
||||
export interface ServerEntity {
|
||||
uuid: string;
|
||||
external_uuid?: string;
|
||||
unique_id?: string;
|
||||
|
||||
device_name?: string;
|
||||
ip?: string; // Для серверов
|
||||
rn_kkt?: string; // Для ФР
|
||||
serial_number?: string;
|
||||
server_name?: string;
|
||||
ip?: string;
|
||||
|
||||
rdp?: string;
|
||||
anydesk?: string;
|
||||
teamviewer?: string;
|
||||
litemanager?: string;
|
||||
partners_link?: string;
|
||||
|
||||
operational_status?: 'active' | 'offline' | 'unknown';
|
||||
health_status?: 'ok' | 'attention_required' | 'locked';
|
||||
// Используем unknown вместо any для безопасной работы с динамическими полями
|
||||
[key: string]: unknown;
|
||||
health_status: 'ok' | 'attention_required' | 'locked';
|
||||
status_details?: unknown;
|
||||
last_polled_at?: string;
|
||||
|
||||
server_version?: string;
|
||||
server_edition?: string;
|
||||
|
||||
crm_id?: string;
|
||||
|
||||
// Добавлено поле address
|
||||
address?: string;
|
||||
description?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface WorkstationEntity {
|
||||
uuid: string;
|
||||
external_uuid?: string;
|
||||
device_name?: string;
|
||||
|
||||
anydesk?: string;
|
||||
teamviewer?: string;
|
||||
litemanager?: string;
|
||||
|
||||
health_status: 'ok' | 'attention_required' | 'locked';
|
||||
status_details?: unknown;
|
||||
|
||||
// Добавлено поле description
|
||||
description?: string;
|
||||
address?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export interface FiscalEntity {
|
||||
uuid: string;
|
||||
external_uuid?: string;
|
||||
|
||||
model_kkt?: string;
|
||||
serial_number?: string;
|
||||
rn_kkt?: string;
|
||||
|
||||
fn_number?: string;
|
||||
fn_registration_date?: string;
|
||||
fn_expire_date?: string;
|
||||
|
||||
driver_version?: string;
|
||||
fr_firmware?: string;
|
||||
fr_downloader?: string;
|
||||
|
||||
organization_name?: string;
|
||||
inn?: string;
|
||||
|
||||
health_status: 'ok' | 'attention_required' | 'locked';
|
||||
status_details?: unknown;
|
||||
|
||||
// Добавлено поле address
|
||||
address?: string;
|
||||
description?: string;
|
||||
|
||||
[key: string]: unknown;
|
||||
}
|
||||
|
||||
export type EntityData = ServerEntity | WorkstationEntity | FiscalEntity;
|
||||
|
||||
export interface InfrastructureItem {
|
||||
entity_type: 'Server' | 'Workstation' | 'FiscalRegister';
|
||||
data: EntityData;
|
||||
}
|
||||
|
||||
// --- Search DTO ---
|
||||
@@ -66,7 +152,6 @@ export interface TaskDTO {
|
||||
entity_type: string;
|
||||
status: TaskStatus;
|
||||
created_at: string;
|
||||
// Детали могут быть любой структуры, но мы знаем, что это объект
|
||||
details: Record<string, unknown>;
|
||||
}
|
||||
|
||||
|
||||
29
src/utils/formatters.ts
Normal file
29
src/utils/formatters.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
import dayjs from 'dayjs';
|
||||
|
||||
export const formatRnm = (rnm?: string): string => {
|
||||
if (!rnm) return '';
|
||||
// Разбиваем по 4 цифры: 0000 1111 2222 3333
|
||||
return rnm.replace(/\D/g, '').replace(/(\d{4})(?=\d)/g, '$1 ').trim();
|
||||
};
|
||||
|
||||
export const cleanWebUrl = (url?: string): string => {
|
||||
if (!url) return '';
|
||||
// Убираем протокол если есть, убираем порт и все после него
|
||||
// co-mirine-co.iikoweb.ru:8080 -> co-mirine-co.iikoweb.ru
|
||||
let clean = url.replace(/https?:\/\//, '');
|
||||
clean = clean.split(':')[0];
|
||||
return clean;
|
||||
};
|
||||
|
||||
export const formatServerEdition = (edition?: string): string => {
|
||||
if (!edition) return '';
|
||||
const lower = edition.toLowerCase();
|
||||
if (lower === 'default') return 'RMS';
|
||||
if (lower === 'chain') return 'Chain';
|
||||
return edition;
|
||||
};
|
||||
|
||||
export const formatDate = (date?: string): string => {
|
||||
if (!date) return '-';
|
||||
return dayjs(date).format('DD.MM.YYYY HH:mm');
|
||||
};
|
||||
Reference in New Issue
Block a user