krona-front/src/features/communications/components/SettingsView.tsx

314 lines
21 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { RefreshCw, Trash2, Globe, UserCog, Bell, Volume2, PanelTop } from 'lucide-react';
import { useCommunicationsStore } from '../services/communicationsService';
import { Button, Badge } from '@omnichannel/ui';
import { useToast } from '../../../shared/ui';
interface SettingsViewProps {
showEmailTab: boolean;
onShowEmailTabChange: (enabled: boolean) => void;
showChannelsTab: boolean;
onShowChannelsTabChange: (enabled: boolean) => void;
orgEmailAllowed: boolean;
onOrgEmailAllowedChange: (enabled: boolean) => void;
orgChannelsAllowed: boolean;
onOrgChannelsAllowedChange: (enabled: boolean) => void;
canConnectAccounts: boolean;
onCanConnectAccountsChange: (enabled: boolean) => void;
}
export function SettingsView({ showEmailTab, onShowEmailTabChange, showChannelsTab, onShowChannelsTabChange, orgEmailAllowed, onOrgEmailAllowedChange, orgChannelsAllowed, onOrgChannelsAllowedChange, canConnectAccounts, onCanConnectAccountsChange }: SettingsViewProps) {
const { showToast } = useToast();
const {
integrations,
testIntegration,
toggleIntegration,
quickReplies,
addQuickReplyTemplate,
deleteQuickReplyTemplate,
auditLogs,
} = useCommunicationsStore();
const [newTemplateText, setNewTemplateText] = useState('');
const [testingId, setTestingId] = useState<string | null>(null);
const [settingsScope, setSettingsScope] = useState<'personal' | 'organization'>('personal');
const [notificationsEnabled, setNotificationsEnabled] = useState(() => localStorage.getItem('krona-user-notifications') !== 'false');
const [soundsEnabled, setSoundsEnabled] = useState(() => localStorage.getItem('krona-user-sounds') !== 'false');
const [compactMode, setCompactMode] = useState(() => localStorage.getItem('krona-user-compact') !== 'false');
const switchClass = (enabled: boolean) => `relative w-12 h-7 rounded-full transition-colors shrink-0 ${enabled ? 'bg-[var(--krona-primary)]' : 'bg-[var(--krona-surface-muted)]'}`;
const switchKnobClass = (enabled: boolean) => `absolute left-1 top-1 w-5 h-5 rounded-full bg-white shadow-sm transition-transform ${enabled ? 'translate-x-5' : 'translate-x-0'}`;
const handleTest = async (id: string) => {
setTestingId(id);
const result = await testIntegration(id);
setTestingId(null);
showToast({
title: 'Диагностика коннектора',
message: `Статус: ОК • Задержка: ${result.latencyMs} мс`,
type: 'success',
});
};
const handleAddTemplate = (e: React.FormEvent) => {
e.preventDefault();
if (!newTemplateText.trim()) return;
addQuickReplyTemplate(newTemplateText.trim());
setNewTemplateText('');
showToast({
title: 'Шаблон добавлен',
message: 'Быстрый ответ сохранен',
type: 'success',
});
};
return (
<div className="flex-1 min-h-0 flex flex-col p-4 sm:p-6 bg-[var(--krona-bg-workspace)] overflow-y-auto space-y-6">
<div className="flex p-1 rounded-xl bg-[var(--krona-surface-muted)] self-start" role="tablist" aria-label="Тип настроек">
<button type="button" role="tab" aria-selected={settingsScope === 'personal'} onClick={() => setSettingsScope('personal')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${settingsScope === 'personal' ? 'bg-[var(--krona-surface)] text-[var(--krona-primary)] shadow-xs' : 'text-[var(--krona-text-secondary)]'}`}>Мои настройки</button>
<button type="button" role="tab" aria-selected={settingsScope === 'organization'} onClick={() => setSettingsScope('organization')} className={`px-4 py-2 rounded-lg text-sm font-semibold transition-colors ${settingsScope === 'organization' ? 'bg-[var(--krona-surface)] text-[var(--krona-primary)] shadow-xs' : 'text-[var(--krona-text-secondary)]'}`}>Организация</button>
</div>
{settingsScope === 'personal' ? <>
<section className="p-4 sm:p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<h3 className="font-bold text-base text-[var(--krona-text-primary)]">Отображение разделов</h3>
<p className="text-xs text-[var(--krona-text-secondary)] mt-0.5 mb-4">Показывайте только те способы связи, которыми пользуется организация</p>
<div className="divide-y divide-[var(--krona-border-subtle)]">
<div className="flex items-center justify-between gap-4 py-3">
<div>
<div className="font-semibold text-sm text-[var(--krona-text-primary)]">Email</div>
<div className="text-xs text-[var(--krona-text-secondary)]">Почтовые ящики и переписка</div>
</div>
<button type="button" role="switch" aria-checked={showEmailTab} disabled={!orgEmailAllowed} onClick={() => onShowEmailTabChange(!showEmailTab)} className={`${switchClass(showEmailTab)} disabled:opacity-40`} aria-label="Показывать раздел Email">
<span className={switchKnobClass(showEmailTab)} />
</button>
</div>
<div className="flex items-center justify-between gap-4 py-3">
<div>
<div className="font-semibold text-sm text-[var(--krona-text-primary)]">Каналы</div>
<p className="text-xs text-[var(--krona-text-secondary)] mt-0.5">Боты и публичные каналы организации</p>
</div>
<button
type="button"
role="switch"
aria-checked={showChannelsTab}
onClick={() => onShowChannelsTabChange(!showChannelsTab)}
disabled={!orgChannelsAllowed}
className={`${switchClass(showChannelsTab)} disabled:opacity-40`}
aria-label="Показывать раздел Каналы"
>
<span className={switchKnobClass(showChannelsTab)} />
</button>
</div>
</div>
</section>
<section className="p-4 sm:p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs divide-y divide-[var(--krona-border-subtle)]">
<h3 className="font-bold text-base text-[var(--krona-text-primary)] pb-3">Поведение и уведомления</h3>
{[
{ label: 'Уведомления', hint: 'Новые сообщения и упоминания', icon: Bell, value: notificationsEnabled, set: setNotificationsEnabled, key: 'krona-user-notifications' },
{ label: 'Звуки', hint: 'Звуковой сигнал новых сообщений', icon: Volume2, value: soundsEnabled, set: setSoundsEnabled, key: 'krona-user-sounds' },
{ label: 'Компактный интерфейс', hint: 'Больше диалогов и сообщений на экране', icon: PanelTop, value: compactMode, set: setCompactMode, key: 'krona-user-compact' },
].map((item) => <div key={item.label} className="flex items-center justify-between gap-4 py-3"><div className="flex items-center gap-3"><item.icon className="w-4 h-4 text-[var(--krona-primary)]"/><div><div className="font-semibold text-sm text-[var(--krona-text-primary)]">{item.label}</div><div className="text-xs text-[var(--krona-text-secondary)]">{item.hint}</div></div></div><button type="button" role="switch" aria-checked={item.value} onClick={() => { const next = !item.value; item.set(next); localStorage.setItem(item.key, String(next)); }} className={switchClass(item.value)} aria-label={item.label}><span className={switchKnobClass(item.value)} /></button></div>)}
</section>
</> : <>
<section className="p-4 sm:p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<div className="flex items-center gap-2 mb-1"><UserCog className="w-5 h-5 text-[var(--krona-primary)]"/><h3 className="font-bold text-base text-[var(--krona-text-primary)]">Доступ организации</h3></div>
<p className="text-xs text-[var(--krona-text-secondary)] mb-4">Разрешения действуют для всех пользователей организации</p>
<div className="divide-y divide-[var(--krona-border-subtle)]">
<div className="flex items-center justify-between gap-4 py-3"><div><div className="font-semibold text-sm">Разрешить Email</div><div className="text-xs text-[var(--krona-text-secondary)]">Пользователи смогут подключать и показывать свои ящики</div></div><button type="button" role="switch" aria-checked={orgEmailAllowed} onClick={() => onOrgEmailAllowedChange(!orgEmailAllowed)} className={switchClass(orgEmailAllowed)} aria-label="Разрешить Email организации"><span className={switchKnobClass(orgEmailAllowed)}/></button></div>
<div className="flex items-center justify-between gap-4 py-3"><div><div className="font-semibold text-sm">Разрешить Каналы</div><div className="text-xs text-[var(--krona-text-secondary)]">Боты и публичные каналы организации</div></div><button type="button" role="switch" aria-checked={orgChannelsAllowed} onClick={() => onOrgChannelsAllowedChange(!orgChannelsAllowed)} className={switchClass(orgChannelsAllowed)} aria-label="Разрешить Каналы организации"><span className={switchKnobClass(orgChannelsAllowed)}/></button></div>
<div className="flex items-center justify-between gap-4 py-3"><div><div className="font-semibold text-sm">Пользователи подключают аккаунты</div><div className="text-xs text-[var(--krona-text-secondary)]">Разрешить добавление личных мессенджеров и почты</div></div><button type="button" role="switch" aria-checked={canConnectAccounts} onClick={() => onCanConnectAccountsChange(!canConnectAccounts)} className={switchClass(canConnectAccounts)} aria-label="Разрешить подключение аккаунтов"><span className={switchKnobClass(canConnectAccounts)}/></button></div>
</div>
</section>
<section className="p-4 sm:p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<div className="flex items-center justify-between gap-3 mb-4"><div><h3 className="font-bold text-base">Пользователи и роли</h3><p className="text-xs text-[var(--krona-text-secondary)]">Кто принимает сообщения и управляет подключениями</p></div><Button size="sm" variant="primary" onClick={() => showToast({ title: 'Добавление пользователя', message: 'Форма приглашения пользователя будет открыта здесь', type: 'info' })}>Добавить пользователя</Button></div>
<div className="space-y-2 text-sm"><div className="flex justify-between p-3 rounded-xl bg-[var(--krona-bg-workspace)]"><span><strong>Виктор</strong><small className="block text-[var(--krona-text-secondary)]">Владелец</small></span><span className="text-xs text-[var(--krona-primary)]">Все права</span></div><div className="flex justify-between p-3 rounded-xl bg-[var(--krona-bg-workspace)]"><span><strong>Мария К.</strong><small className="block text-[var(--krona-text-secondary)]">Менеджер</small></span><span className="text-xs text-[var(--krona-text-secondary)]">Чаты и почта</span></div></div>
</section>
{/* 1. Connected Messengers & Connectors */}
<section className="p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-bold text-base text-[var(--krona-text-primary)]">
Подключенные мессенджеры и коннекторы
</h3>
<p className="text-xs text-[var(--krona-text-secondary)] mt-0.5">
Управление внешними каналами связи и интеграциями
</p>
</div>
<span className="text-xs font-semibold text-emerald-600 flex items-center gap-1.5">
<span className="w-2 h-2 rounded-full bg-emerald-500 animate-pulse" />
Все коннекторы активны
</span>
</div>
<div className="space-y-3">
{integrations.map((item) => (
<div
key={item.id}
className="p-3.5 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border-subtle)] flex flex-col sm:flex-row sm:items-center justify-between gap-3"
>
<div className="flex items-center gap-3">
<div className="w-10 h-10 rounded-xl bg-[var(--krona-surface-raised)] border border-[var(--krona-border)] flex items-center justify-center font-bold text-sm text-[var(--krona-primary)] shrink-0">
{item.title[0]}
</div>
<div>
<div className="flex items-center gap-2">
<span className="font-bold text-sm text-[var(--krona-text-primary)]">
{item.title}
</span>
<Badge variant={item.status === 'active' ? 'success' : 'danger'} size="sm">
{item.status === 'active' ? 'Подключен' : 'Отключен'}
</Badge>
</div>
<span className="text-xs text-[var(--krona-text-secondary)]">
{item.accountIdentifier} • Синхронизация: {item.lastSync} • Лимит: {item.rateLimit}
</span>
</div>
</div>
<div className="flex items-center gap-2 self-end sm:self-auto">
<Button
variant="secondary"
size="sm"
isLoading={testingId === item.id}
onClick={() => handleTest(item.id)}
leftIcon={<RefreshCw className="w-3.5 h-3.5" />}
>
Тест связи
</Button>
<Button
variant={item.status === 'active' ? 'danger' : 'secondary'}
size="sm"
onClick={() => toggleIntegration(item.id)}
>
{item.status === 'active' ? 'Отключить' : 'Включить'}
</Button>
</div>
</div>
))}
</div>
</section>
{/* 2. Network & VPN Gateway (Sing-box) */}
<section className="p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<div className="flex items-center justify-between mb-4">
<div>
<h3 className="font-bold text-base text-[var(--krona-text-primary)] flex items-center gap-2">
<Globe className="w-5 h-5 text-[var(--krona-primary)]" />
Сетевой шлюз и VPN (Sing-box / Wireguard)
</h3>
<p className="text-xs text-[var(--krona-text-secondary)] mt-0.5">
Обеспечение бесперебойного доступа к международным API Telegram и WhatsApp
</p>
</div>
<div className="flex items-center gap-2"><span className="px-2.5 py-1 rounded-full bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 font-bold text-xs">Туннель активен (14 ms)</span><Button size="sm" variant="secondary" onClick={() => showToast({ title: 'Настройка VPN', message: 'Открыта настройка единого шлюза организации', type: 'info' })}>Настроить VPN</Button></div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 text-xs">
<div className="p-3 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border-subtle)]">
<span className="text-[var(--krona-text-muted)] block">Протокол маршрутизации</span>
<strong className="text-sm text-[var(--krona-text-primary)] block mt-0.5">VLESS + XTLS Vision</strong>
</div>
<div className="p-3 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border-subtle)]">
<span className="text-[var(--krona-text-muted)] block">Шлюз и прокси-нода</span>
<strong className="text-sm text-[var(--krona-text-primary)] block mt-0.5">eu-west.krona.systems</strong>
</div>
<div className="p-3 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border-subtle)]">
<span className="text-[var(--krona-text-muted)] block">Uptime туннеля</span>
<strong className="text-sm text-emerald-600 block mt-0.5">99.98% (30 дней)</strong>
</div>
</div>
</section>
{/* 3. Quick Replies Manager */}
<section className="p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<h3 className="font-bold text-base text-[var(--krona-text-primary)] mb-1">
Шаблоны быстрых ответов
</h3>
<p className="text-xs text-[var(--krona-text-secondary)] mb-4">
Шаблоны доступны операторам в один клик при общении с клиентами
</p>
<form onSubmit={handleAddTemplate} className="flex gap-2 mb-4">
<input
type="text"
value={newTemplateText}
onChange={(e) => setNewTemplateText(e.target.value)}
placeholder="Введите новый шаблон ответа..."
className="flex-1 h-10 px-3 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border)] text-sm text-[var(--krona-text-primary)] placeholder-[var(--krona-text-muted)] focus:outline-none focus:border-[var(--krona-primary)]"
/>
<Button type="submit" variant="primary" disabled={!newTemplateText.trim()}>
Добавить
</Button>
</form>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{quickReplies.map((tmpl, idx) => (
<div
key={idx}
className="p-3 rounded-xl bg-[var(--krona-bg-workspace)] border border-[var(--krona-border-subtle)] flex items-center justify-between gap-2 text-xs text-[var(--krona-text-primary)]"
>
<span>{tmpl}</span>
<button
type="button"
onClick={() => deleteQuickReplyTemplate(idx)}
className="text-[var(--krona-text-muted)] hover:text-red-600 p-1 transition-colors"
title="Удалить шаблон"
>
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
))}
</div>
</section>
{/* 4. Connection & Routing Audit Log */}
<section className="p-5 rounded-2xl bg-[var(--krona-surface)] border border-[var(--krona-border-subtle)] shadow-xs">
<h3 className="font-bold text-base text-[var(--krona-text-primary)] mb-1">
Журнал подключений и событий шлюза
</h3>
<p className="text-xs text-[var(--krona-text-secondary)] mb-4">
Сквозной аудит всех входящих и исходящих сессий
</p>
<div className="overflow-x-auto">
<table className="w-full text-left text-xs">
<thead>
<tr className="border-b border-[var(--krona-border)] text-[var(--krona-text-secondary)]">
<th className="py-2 px-3 font-semibold">Время</th>
<th className="py-2 px-3 font-semibold">Источник</th>
<th className="py-2 px-3 font-semibold">Событие</th>
<th className="py-2 px-3 font-semibold">Оператор / Сервис</th>
<th className="py-2 px-3 font-semibold">Статус</th>
</tr>
</thead>
<tbody className="divide-y divide-[var(--krona-border-subtle)]">
{auditLogs.map((log) => (
<tr key={log.id} className="hover:bg-[var(--krona-surface-hover)]">
<td className="py-2.5 px-3 text-[var(--krona-text-muted)] font-mono">
{log.timestamp}
</td>
<td className="py-2.5 px-3 font-semibold text-[var(--krona-text-primary)]">
{log.source}
</td>
<td className="py-2.5 px-3 text-[var(--krona-text-primary)]">
{log.event}
</td>
<td className="py-2.5 px-3 text-[var(--krona-text-secondary)]">
{log.operator}
</td>
<td className="py-2.5 px-3">
<span className="px-2 py-0.5 rounded-full bg-emerald-100 dark:bg-emerald-950 text-emerald-700 dark:text-emerald-300 font-semibold text-[10px]">
Успех
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</section>
</>}
</div>
);
}