14.12.25 - Этап 2 на фронте. Написаны отдельные компоненты для оборудки, страница компании, немного поправил светлую тему

This commit is contained in:
2025-12-14 02:00:36 +03:00
parent cd497c4b26
commit f25ce1a469
12 changed files with 805 additions and 82 deletions

View File

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

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

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

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

View File

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

View File

@@ -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={
<Link to={`/companies/${group.owner.uuid}`} style={{ color: 'inherit' }}>
<Space>
{getEntityIcon('Company')}
<Text strong>{group.owner.name}</Text>
<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>

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

View File

@@ -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,28 +21,31 @@ 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,
}
},

View File

@@ -26,21 +26,107 @@ 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 для безопасной работы с динамическими полями
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 ---
export interface SearchFoundEntity {
entity_type: 'Server' | 'Workstation' | 'FiscalRegister';
@@ -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
View 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');
};