diff --git a/src/App.tsx b/src/App.tsx index 19a380c..b91afb2 100644 --- a/src/App.tsx +++ b/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 ; } - // 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 */} } /> } /> } /> Тикеты} /> - Компании} /> - Серверы} /> - РС} /> - ФР} /> + + Список компаний} /> + } /> + + {/* Роуты для оборудования */} + Список серверов} /> + Детали сервера (в разработке)} /> + + Список РС} /> + Детали РС (в разработке)} /> + + Список ФР} /> + Детали ФР (в разработке)} /> + Админка} /> diff --git a/src/api/companies.ts b/src/api/companies.ts new file mode 100644 index 0000000..cf01b8f --- /dev/null +++ b/src/api/companies.ts @@ -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>(`/companies/${id}`); + return response.data; + }, + + // Получение инфраструктуры (CMDB) + getInfrastructure: async (id: string) => { + const response = await apiClient.get>(`/companies/${id}/infrastructure`); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/api/equipment.ts b/src/api/equipment.ts new file mode 100644 index 0000000..34c7835 --- /dev/null +++ b/src/api/equipment.ts @@ -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>(`/servers/${uuid}/poll`); + return response.data; + }, +}; \ No newline at end of file diff --git a/src/components/entities/FiscalCard.tsx b/src/components/entities/FiscalCard.tsx new file mode 100644 index 0000000..8d6bb30 --- /dev/null +++ b/src/components/entities/FiscalCard.tsx @@ -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 = ({ data }) => { + const navigate = useNavigate(); + + const renderFnInfo = (dateStr?: string) => { + if (!dateStr) return Нет ФН; + 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 ( + + {label} + + (до {expireDate.format('DD.MM.YYYY')}) + + + ); + }; + + const handleCardClick = () => { + navigate(`/fiscals/${data.uuid}`); + }; + + return ( + + {getEntityIcon('FiscalRegister')} + {data.model_kkt || 'ККТ'} + + } + extra={ + + + + } + > +
+ {data.organization_name} + {data.inn && ИНН: {data.inn}} + {/* address теперь типизирован как string | undefined, проверка безопасна */} + {data.address && | {data.address}} +
+ + +
+ РНМ: + + {formatRnm(data.rn_kkt)} + +
+ +
+ SN: + + {data.serial_number} + +
+ +
+ {renderFnInfo(data.fn_expire_date)} +
+ + {(data.driver_version || data.fr_firmware) && ( +
+ FW: {data.fr_firmware} {data.driver_version ? `| Drv: ${data.driver_version}` : ''} +
+ )} +
+
+ ); +}; + +export default FiscalCard; \ No newline at end of file diff --git a/src/components/entities/ServerCard.tsx b/src/components/entities/ServerCard.tsx new file mode 100644 index 0000000..db8d489 --- /dev/null +++ b/src/components/entities/ServerCard.tsx @@ -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 = ({ 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 ( + + + {data.device_name || 'Cloud Server'} + + } + > + + {/* Row 1: Address Copy + Link Button */} +
+ + {webUrl} + + +
+ + {/* Row 2: Version + Type */} +
+ Версия: + {data.server_version || '-'} {formatServerEdition(data.server_edition) || 'Web'} +
+ + {/* Row 3: UID */} + {data.unique_id && ( +
+ UID: + + {data.unique_id.substring(0, 15)}... + +
+ )} + + {/* Action: Poll Button with Last Polled Date */} +
+ +
+
+
+ ); + } + + // --- Рендер для обычных серверов (RMS) --- + const renderAccessLink = (label: string, value?: string) => { + if (!value) return null; + return ( + + + {label}: {value} + + + ); + }; + + return ( + + {getEntityIcon('Server')} + {data.device_name || data.server_name || 'Unknown Server'} + + } + extra={ + + + + + + + + + } + actions={[ + + ]} + > +
+
+ IP: + + {data.ip || 'No IP'} + +
+
+ Версия: + {data.server_version} {data.server_edition ? `(${formatServerEdition(data.server_edition)})` : ''} +
+ {data.partners_link && ( +
+ +
+ )} +
+ +
+ + {renderAccessLink('AnyDesk', data.anydesk)} + {renderAccessLink('TeamViewer', data.teamviewer)} + {renderAccessLink('RDP', data.rdp)} + {renderAccessLink('LM', data.litemanager)} + + {!data.anydesk && !data.teamviewer && !data.rdp && !data.litemanager && ( + Нет данных для доступа + )} + +
+
+ ); +}; + +export default ServerCard; \ No newline at end of file diff --git a/src/components/entities/WorkstationCard.tsx b/src/components/entities/WorkstationCard.tsx new file mode 100644 index 0000000..a0b7603 --- /dev/null +++ b/src/components/entities/WorkstationCard.tsx @@ -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 = ({ data }) => { + const navigate = useNavigate(); + + const handleCardClick = () => { + navigate(`/workstations/${data.uuid}`); + }; + + return ( + + {getEntityIcon('Workstation')} + {data.device_name || 'Workstation'} + + } + extra={ + + + + } + > + + {/* description теперь типизирован как string | undefined */} + {data.description && ( + + {data.description} + + )} + + {data.anydesk && ( +
+ AnyDesk: + {data.anydesk} +
+ )} + + {data.teamviewer && ( +
+ TV: + {data.teamviewer} +
+ )} + + {data.litemanager && ( +
+ LM: + {data.litemanager} +
+ )} + + {!data.anydesk && !data.teamviewer && !data.litemanager && !data.description && ( + Нет доп. информации + )} +
+
+ ); +}; + +export default WorkstationCard; \ No newline at end of file diff --git a/src/index.css b/src/index.css index 53fd539..260ccae 100644 --- a/src/index.css +++ b/src/index.css @@ -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; } \ No newline at end of file diff --git a/src/pages/SearchPage.tsx b/src/pages/SearchPage.tsx index 65a0d20..5946256 100644 --- a/src/pages/SearchPage.tsx +++ b/src/pages/SearchPage.tsx @@ -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 ; + case 'Workstation': + return ; + case 'FiscalRegister': + return ; + default: + return
Unknown entity type
; + } + }; + return (
Результаты поиска: "{term}" @@ -46,53 +62,30 @@ const SearchPage: React.FC = () => { - {getEntityIcon('Company')} - {group.owner.name} - {group.owner.address} - + + + {getEntityIcon('Company')} + + {group.owner.name} + + {group.owner.address} + + } className="glass-panel" - extra={} + extra={ + + + + } > - { - 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 ( - - - {getEntityIcon(item.entity_type)} -
- } - title={ - - {title} - - - } - description={ - - {getEntityLabel(item.entity_type)} - {subtitle} - - } - /> - - - ); - }} - /> + + {group.found_entities.map((item, idx) => ( + + {renderEntityCard(item)} + + ))} + ))} diff --git a/src/pages/companies/CompanyPage.tsx b/src/pages/companies/CompanyPage.tsx new file mode 100644 index 0000000..6ca66f8 --- /dev/null +++ b/src/pages/companies/CompanyPage.tsx @@ -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
; + if (!company) return ; + + const items = [ + { + key: 'infrastructure', + label: 'Инфраструктура', + children: ( +
+ {loadingInfra ? : ( + <> + {/* Секция Серверов */} + {groupedInfra.servers.length > 0 && ( +
+ Серверы ({groupedInfra.servers.length}) + + {groupedInfra.servers.map(srv => ( + + + + ))} + +
+ )} + + {/* Секция Касс */} + {groupedInfra.fiscals.length > 0 && ( +
+ Фискальные регистраторы ({groupedInfra.fiscals.length}) + + {groupedInfra.fiscals.map(fr => ( + + + + ))} + +
+ )} + + {/* Секция Рабочих станций */} + {groupedInfra.workstations.length > 0 && ( +
+ Рабочие станции ({groupedInfra.workstations.length}) + + {groupedInfra.workstations.map(ws => ( + + + + ))} + +
+ )} + + {(!rawInfrastructure || rawInfrastructure.length === 0) && } + + )} +
+ ), + }, + { + key: 'tickets', + label: 'Тикеты', + children: , + }, + { + key: 'contracts', + label: 'Контракты', + children: , + }, + ]; + + return ( +
+ {/* Header Профиля */} + + + Назад к списку + + +
+
+
+ +
+
+ {company.Title} + {company.Address} +
+
+ +
+ {company.ActiveContract ? ( + } color="success">Контракт Активен + ) : ( + } color="error">Нет контракта + )} +
+ ID: {company.ID} +
+
+
+ + + {company.AdditionalName || '-'} + + {company.ParentID ? {company.ParentID} : '-'} + + + {company.LastModifiedDate ? new Date(company.LastModifiedDate).toLocaleDateString() : '-'} + + +
+ + {/* Основной контент */} + +
+ ); +}; + +export default CompanyPage; \ No newline at end of file diff --git a/src/theme/themeConfig.ts b/src/theme/themeConfig.ts index e303a7a..74319da 100644 --- a/src/theme/themeConfig.ts +++ b/src/theme/themeConfig.ts @@ -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, } }, }; diff --git a/src/types/api.ts b/src/types/api.ts index 0d7b64f..4f231f4 100644 --- a/src/types/api.ts +++ b/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; } diff --git a/src/utils/formatters.ts b/src/utils/formatters.ts new file mode 100644 index 0000000..051b593 --- /dev/null +++ b/src/utils/formatters.ts @@ -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'); +}; \ No newline at end of file