Initial commit: VKUS vitrina MVP (frontend + backend).

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
2026-06-29 23:13:06 +07:00
commit 862e6b7b25
113 changed files with 50949 additions and 0 deletions
+46
View File
@@ -0,0 +1,46 @@
.app {
display: flex;
flex-direction: column;
min-height: 100vh;
background: var(--bg);
}
.app-loading {
display: flex;
align-items: center;
justify-content: center;
min-height: 100vh;
color: var(--text-muted);
}
.app-error {
padding: 10px 32px;
font-size: 0.88rem;
background: #fef2f2;
color: #b91c1c;
border-bottom: 1px solid #fecaca;
}
.main-content {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.main-content--chat {
overflow: hidden;
}
.main-content:not(.main-content--chat) {
max-width: 1200px;
width: 100%;
margin: 0 auto;
padding: 24px 32px 40px;
}
@media (max-width: 768px) {
.main-content:not(.main-content--chat) {
padding: 16px;
}
}
+71
View File
@@ -0,0 +1,71 @@
import { useEffect, useState } from 'react';
import type { TabId, Appeal } from './types';
import { Header } from './components/Header';
import { VitrinaView } from './components/VitrinaView';
import { AppealsView } from './components/AppealsView';
import { CatalogView } from './components/CatalogView';
import { api } from './api/client';
import './App.css';
function App() {
const [activeTab, setActiveTab] = useState<TabId>('vitrina');
const [appeals, setAppeals] = useState<Appeal[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadAppeals = async () => {
try {
const data = await api.getAppeals();
setAppeals(data);
setError(null);
} catch {
setError('Не удалось подключиться к серверу. Запустите Python backend на порту 8000.');
} finally {
setLoading(false);
}
};
useEffect(() => {
loadAppeals();
}, []);
const handleTicketCreated = (appeal: Appeal) => {
setAppeals((prev) => [appeal, ...prev]);
setActiveTab('appeals');
};
const handleCatalogSelect = (query: string) => {
setActiveTab('vitrina');
window.setTimeout(() => {
window.dispatchEvent(new CustomEvent('vkus-prefill', { detail: query }));
}, 0);
};
if (loading) {
return (
<div className="app app-loading">
<p>Загрузка...</p>
</div>
);
}
return (
<div className="app">
<Header activeTab={activeTab} onTabChange={setActiveTab} appeals={appeals} />
{error && <div className="app-error">{error}</div>}
<main className={`main-content ${activeTab === 'vitrina' ? 'main-content--chat' : ''}`}>
{activeTab === 'vitrina' && (
<VitrinaView
appeals={appeals}
onTicketCreated={handleTicketCreated}
onGoToAppeals={() => setActiveTab('appeals')}
/>
)}
{activeTab === 'appeals' && <AppealsView appeals={appeals} />}
{activeTab === 'catalog' && <CatalogView onSelectService={handleCatalogSelect} />}
</main>
</div>
);
}
export default App;
+76
View File
@@ -0,0 +1,76 @@
import type {
Appeal,
AnalyticsSummary,
AssistantResult,
ConversationTurn,
CreateAppealPayload,
FeedbackPayload,
NotificationItem,
ServiceItem,
UserProfile,
} from '../types';
const API_BASE = '/api';
async function request<T>(path: string, options?: RequestInit): Promise<T> {
const response = await fetch(`${API_BASE}${path}`, {
headers: { 'Content-Type': 'application/json', ...options?.headers },
...options,
});
if (!response.ok) {
const text = await response.text().catch(() => '');
throw new Error(text || `API error: ${response.status}`);
}
return response.json() as Promise<T>;
}
export const api = {
health: () => request<{ status: string }>('/health'),
getProfile: () => request<UserProfile>('/profile'),
getPrompts: () => request<string[]>('/prompts'),
sendMessage: (message: string, isFirstMessage: boolean, conversation: ConversationTurn[] = []) =>
request<AssistantResult>('/chat', {
method: 'POST',
body: JSON.stringify({ message, isFirstMessage, conversation }),
}),
getAppeals: () => request<Appeal[]>('/appeals'),
createAppeal: (payload: CreateAppealPayload) =>
request<Appeal>('/appeals', {
method: 'POST',
body: JSON.stringify(payload),
}),
getServices: () => request<ServiceItem[]>('/services'),
submitFeedback: (payload: FeedbackPayload) =>
request<{ ok: boolean }>('/feedback', {
method: 'POST',
body: JSON.stringify({
messageId: payload.messageId,
helped: payload.helped,
query: payload.query,
system: payload.system,
confidence: payload.confidence,
}),
}),
getAnalyticsSummary: () => request<AnalyticsSummary>('/analytics/summary'),
getNotifications: () => request<NotificationItem[]>('/notifications'),
getUnreadNotificationsCount: () =>
request<{ count: number }>('/notifications/unread-count'),
markNotificationRead: (id: string) =>
request<{ ok: boolean }>(`/notifications/${id}/read`, { method: 'POST' }),
markAllNotificationsRead: () =>
request<{ marked: number }>('/notifications/read-all', { method: 'POST' }),
};
+397
View File
@@ -0,0 +1,397 @@
.appeals-header {
margin-bottom: 20px;
}
.appeals-title {
margin: 0 0 4px;
font-size: 1.25rem;
font-weight: 700;
}
.appeals-desc {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
}
.appeals-stats {
display: grid;
grid-template-columns: repeat(5, 1fr);
gap: 12px;
margin-bottom: 20px;
}
.stat-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 16px;
text-align: center;
}
.stat-value {
display: block;
font-size: 1.5rem;
font-weight: 700;
line-height: 1.2;
}
.stat-label {
font-size: 0.78rem;
color: var(--text-muted);
}
.stat-card.highlight-new .stat-value { color: #2563eb; }
.stat-card.highlight-progress .stat-value { color: #d97706; }
.stat-card.highlight-waiting .stat-value { color: #7c3aed; }
.stat-card.highlight-resolved .stat-value { color: var(--primary); }
.appeals-toolbar {
margin-bottom: 16px;
}
.appeals-search {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-bottom: 12px;
}
.appeals-search svg {
color: var(--text-muted);
flex-shrink: 0;
}
.appeals-search input {
flex: 1;
border: none;
background: none;
font-size: 0.9rem;
outline: none;
}
.appeals-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.filter-chip {
padding: 6px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: var(--surface);
font-size: 0.82rem;
color: var(--text-muted);
transition: all 0.15s;
}
.filter-chip:hover {
border-color: var(--primary);
color: var(--primary);
}
.filter-chip.active {
background: var(--primary);
border-color: var(--primary);
color: #fff;
}
.appeals-layout {
display: grid;
grid-template-columns: 380px 1fr;
gap: 16px;
align-items: start;
}
.appeals-list {
display: flex;
flex-direction: column;
gap: 8px;
max-height: calc(100vh - 380px);
overflow-y: auto;
}
.appeals-empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
.appeal-card {
position: relative;
text-align: left;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 14px 36px 14px 14px;
cursor: pointer;
transition: border-color 0.15s, box-shadow 0.15s;
}
.appeal-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow);
}
.appeal-card.selected {
border-color: var(--primary);
background: var(--primary-light);
}
.appeal-card-top {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 6px;
}
.appeal-number {
font-size: 0.78rem;
font-weight: 600;
color: var(--primary);
}
.appeal-status {
font-size: 0.72rem;
font-weight: 600;
padding: 2px 8px;
border-radius: 10px;
}
.status-new { background: #dbeafe; color: #1d4ed8; }
.status-progress { background: #fef3c7; color: #b45309; }
.status-waiting { background: #ede9fe; color: #6d28d9; }
.status-resolved { background: var(--accent-light); color: var(--accent); }
.status-closed { background: #f1f5f9; color: #64748b; }
.appeal-title {
margin: 0 0 6px;
font-size: 0.9rem;
font-weight: 600;
line-height: 1.35;
}
.appeal-meta {
font-size: 0.78rem;
color: var(--text-muted);
display: flex;
gap: 6px;
}
.appeal-arrow {
position: absolute;
right: 12px;
top: 50%;
transform: translateY(-50%);
color: var(--text-muted);
}
.appeal-detail {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 20px;
box-shadow: var(--shadow);
position: sticky;
top: 120px;
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.detail-header h3 {
margin: 6px 0 0;
font-size: 1.05rem;
line-height: 1.35;
}
.detail-next-step {
display: flex;
gap: 12px;
background: var(--primary-light);
border-radius: var(--radius-sm);
padding: 12px 14px;
margin-bottom: 16px;
}
.detail-next-step svg {
color: var(--primary);
flex-shrink: 0;
margin-top: 2px;
}
.detail-next-step strong {
display: block;
font-size: 0.82rem;
margin-bottom: 2px;
}
.detail-next-step p {
margin: 0;
font-size: 0.88rem;
line-height: 1.4;
}
.detail-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
margin-bottom: 16px;
}
.detail-item {
font-size: 0.85rem;
}
.detail-label {
display: block;
font-size: 0.75rem;
color: var(--text-muted);
margin-bottom: 2px;
}
.detail-section {
margin-bottom: 16px;
}
.detail-section h4 {
margin: 0 0 8px;
font-size: 0.88rem;
font-weight: 600;
}
.detail-section p {
margin: 0;
font-size: 0.88rem;
line-height: 1.5;
color: var(--text-muted);
}
.detail-section.resolution {
background: var(--accent-light);
border-radius: var(--radius-sm);
padding: 12px;
}
.detail-section.resolution p {
color: var(--text);
}
.comments-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.comment {
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 10px 12px;
}
.comment.support {
border-left: 3px solid var(--primary);
}
.comment-header {
display: flex;
justify-content: space-between;
font-size: 0.78rem;
margin-bottom: 4px;
}
.comment-header span {
color: var(--text-muted);
}
.comment p {
margin: 0;
font-size: 0.85rem;
line-height: 1.45;
}
.detail-comment-form {
margin-bottom: 16px;
}
.detail-comment-form textarea {
width: 100%;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.88rem;
margin-bottom: 8px;
resize: vertical;
}
.detail-comment-form .btn-primary {
display: inline-flex;
align-items: center;
gap: 6px;
}
.detail-actions {
display: flex;
gap: 8px;
padding-top: 14px;
border-top: 1px solid var(--border);
}
.action-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
background: var(--surface);
font-size: 0.85rem;
color: var(--text);
}
.action-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.action-btn.danger:hover {
border-color: var(--danger);
color: var(--danger);
}
@media (max-width: 900px) {
.appeals-stats {
grid-template-columns: repeat(3, 1fr);
}
.appeals-layout {
grid-template-columns: 1fr;
}
.appeal-detail {
position: static;
}
}
@media (max-width: 500px) {
.appeals-stats {
grid-template-columns: repeat(2, 1fr);
}
.detail-grid {
grid-template-columns: 1fr;
}
}
+257
View File
@@ -0,0 +1,257 @@
import { useMemo, useState } from 'react';
import {
Search,
Clock,
ChevronRight,
MessageSquare,
RotateCcw,
XCircle,
} from 'lucide-react';
import type { Appeal, AppealStatus } from '../types';
import './AppealsView.css';
interface AppealsViewProps {
appeals: Appeal[];
}
const statusFilters: { id: AppealStatus | 'all'; label: string }[] = [
{ id: 'all', label: 'Все' },
{ id: 'new', label: 'Новые' },
{ id: 'in_progress', label: 'В работе' },
{ id: 'waiting', label: 'Ожидает ответа' },
{ id: 'resolved', label: 'Решено' },
{ id: 'closed', label: 'Закрыто' },
];
function statusClass(status: AppealStatus): string {
const map: Record<AppealStatus, string> = {
new: 'status-new',
in_progress: 'status-progress',
waiting: 'status-waiting',
resolved: 'status-resolved',
closed: 'status-closed',
};
return map[status];
}
export function AppealsView({ appeals }: AppealsViewProps) {
const [search, setSearch] = useState('');
const [filter, setFilter] = useState<AppealStatus | 'all'>('all');
const [selected, setSelected] = useState<Appeal | null>(null);
const [comment, setComment] = useState('');
const stats = useMemo(() => {
const counts = { total: appeals.length, new: 0, in_progress: 0, waiting: 0, resolved: 0, closed: 0 };
appeals.forEach((a) => {
counts[a.status]++;
});
return counts;
}, [appeals]);
const filtered = useMemo(() => {
return appeals.filter((a) => {
if (filter !== 'all' && a.status !== filter) return false;
if (!search) return true;
const q = search.toLowerCase();
return (
a.number.toLowerCase().includes(q) ||
a.title.toLowerCase().includes(q) ||
a.system.toLowerCase().includes(q)
);
});
}, [appeals, filter, search]);
return (
<div className="appeals">
<div className="appeals-header">
<div>
<h2 className="appeals-title">История моих обращений</h2>
<p className="appeals-desc">
Статусы заявок в ITSM на понятном языке с указанием следующего шага
</p>
</div>
</div>
<div className="appeals-stats">
<div className="stat-card">
<span className="stat-value">{stats.total}</span>
<span className="stat-label">Всего</span>
</div>
<div className="stat-card highlight-new">
<span className="stat-value">{stats.new}</span>
<span className="stat-label">Новые</span>
</div>
<div className="stat-card highlight-progress">
<span className="stat-value">{stats.in_progress}</span>
<span className="stat-label">В работе</span>
</div>
<div className="stat-card highlight-waiting">
<span className="stat-value">{stats.waiting}</span>
<span className="stat-label">Ожидает ответа</span>
</div>
<div className="stat-card highlight-resolved">
<span className="stat-value">{stats.resolved}</span>
<span className="stat-label">Решено</span>
</div>
</div>
<div className="appeals-toolbar">
<div className="appeals-search">
<Search size={18} />
<input
placeholder="Поиск по номеру, теме, системе..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="appeals-filters">
{statusFilters.map((f) => (
<button
key={f.id}
className={`filter-chip ${filter === f.id ? 'active' : ''}`}
onClick={() => setFilter(f.id)}
>
{f.label}
</button>
))}
</div>
</div>
<div className="appeals-layout">
<div className="appeals-list">
{filtered.length === 0 ? (
<div className="appeals-empty">Обращения не найдены</div>
) : (
filtered.map((appeal) => (
<button
key={appeal.id}
className={`appeal-card ${selected?.id === appeal.id ? 'selected' : ''}`}
onClick={() => setSelected(appeal)}
>
<div className="appeal-card-top">
<span className="appeal-number">{appeal.number}</span>
<span className={`appeal-status ${statusClass(appeal.status)}`}>
{appeal.statusLabel}
</span>
</div>
<h4 className="appeal-title">{appeal.title}</h4>
<div className="appeal-meta">
<span>{appeal.system}</span>
<span>·</span>
<span>{new Date(appeal.updatedAt).toLocaleDateString('ru-RU')}</span>
</div>
<ChevronRight size={16} className="appeal-arrow" />
</button>
))
)}
</div>
{selected && (
<div className="appeal-detail animate-in">
<div className="detail-header">
<div>
<span className="appeal-number">{selected.number}</span>
<h3>{selected.title}</h3>
</div>
<span className={`appeal-status ${statusClass(selected.status)}`}>
{selected.statusLabel}
</span>
</div>
<div className="detail-next-step">
<Clock size={16} />
<div>
<strong>Следующий шаг</strong>
<p>{selected.nextStep}</p>
</div>
</div>
<div className="detail-grid">
<div className="detail-item">
<span className="detail-label">Система</span>
<span>{selected.system}</span>
</div>
<div className="detail-item">
<span className="detail-label">Приоритет</span>
<span>{selected.priorityLabel}</span>
</div>
<div className="detail-item">
<span className="detail-label">Создано</span>
<span>{new Date(selected.createdAt).toLocaleString('ru-RU')}</span>
</div>
<div className="detail-item">
<span className="detail-label">Обновлено</span>
<span>{new Date(selected.updatedAt).toLocaleString('ru-RU')}</span>
</div>
{selected.assignee && (
<div className="detail-item">
<span className="detail-label">Исполнитель</span>
<span>{selected.assignee}</span>
</div>
)}
</div>
<div className="detail-section">
<h4>Описание</h4>
<p>{selected.description}</p>
</div>
{selected.resolution && (
<div className="detail-section resolution">
<h4>Результат</h4>
<p>{selected.resolution}</p>
</div>
)}
{selected.comments.length > 0 && (
<div className="detail-section">
<h4>Комментарии</h4>
<div className="comments-list">
{selected.comments.map((c) => (
<div key={c.id} className={`comment ${c.isSupport ? 'support' : ''}`}>
<div className="comment-header">
<strong>{c.author}</strong>
<span>{new Date(c.date).toLocaleString('ru-RU')}</span>
</div>
<p>{c.text}</p>
</div>
))}
</div>
</div>
)}
{selected.status === 'waiting' && (
<div className="detail-comment-form">
<textarea
placeholder="Добавьте комментарий для поддержки..."
value={comment}
onChange={(e) => setComment(e.target.value)}
rows={3}
/>
<button className="btn-primary" disabled={!comment.trim()}>
<MessageSquare size={16} />
Отправить
</button>
</div>
)}
<div className="detail-actions">
{selected.status === 'resolved' && (
<button className="action-btn">
<RotateCcw size={16} />
Переоткрыть
</button>
)}
{(selected.status === 'new' || selected.status === 'waiting') && (
<button className="action-btn danger">
<XCircle size={16} />
Отменить
</button>
)}
</div>
</div>
)}
</div>
</div>
);
}
+151
View File
@@ -0,0 +1,151 @@
.catalog-header {
margin-bottom: 20px;
}
.catalog-title {
margin: 0 0 4px;
font-size: 1.25rem;
font-weight: 700;
}
.catalog-desc {
margin: 0;
color: var(--text-muted);
font-size: 0.88rem;
max-width: 640px;
}
.catalog-toolbar {
margin-bottom: 20px;
}
.catalog-search {
display: flex;
align-items: center;
gap: 10px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius-sm);
padding: 10px 14px;
margin-bottom: 12px;
}
.catalog-search svg {
color: var(--text-muted);
}
.catalog-search input {
flex: 1;
border: none;
background: none;
font-size: 0.9rem;
outline: none;
}
.catalog-filters {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.catalog-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
gap: 14px;
}
.service-card {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 18px;
display: flex;
flex-direction: column;
transition: border-color 0.15s, box-shadow 0.15s;
}
.service-card:hover {
border-color: var(--primary);
box-shadow: var(--shadow);
}
.service-card-header {
display: flex;
justify-content: space-between;
align-items: center;
gap: 8px;
margin-bottom: 10px;
}
.service-system {
font-size: 0.75rem;
font-weight: 600;
color: var(--primary);
background: var(--primary-light);
padding: 3px 10px;
border-radius: 12px;
}
.service-category {
font-size: 0.72rem;
color: var(--text-muted);
}
.service-title {
margin: 0 0 8px;
font-size: 1rem;
font-weight: 600;
line-height: 1.35;
}
.service-desc {
margin: 0 0 14px;
font-size: 0.85rem;
color: var(--text-muted);
line-height: 1.45;
flex: 1;
}
.service-footer {
display: flex;
justify-content: space-between;
align-items: center;
gap: 10px;
padding-top: 12px;
border-top: 1px solid var(--border);
}
.service-sla {
display: flex;
align-items: center;
gap: 4px;
font-size: 0.78rem;
color: var(--text-muted);
}
.service-action {
display: inline-flex;
align-items: center;
gap: 4px;
padding: 6px 12px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-size: 0.8rem;
font-weight: 500;
transition: background 0.15s;
}
.service-action:hover {
background: var(--primary-hover);
}
.catalog-empty {
padding: 40px;
text-align: center;
color: var(--text-muted);
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
}
+103
View File
@@ -0,0 +1,103 @@
import { useEffect, useMemo, useState } from 'react';
import { Search, Clock, ArrowRight } from 'lucide-react';
import { api } from '../api/client';
import type { ServiceItem } from '../types';
import './CatalogView.css';
interface CatalogViewProps {
onSelectService: (query: string) => void;
}
export function CatalogView({ onSelectService }: CatalogViewProps) {
const [services, setServices] = useState<ServiceItem[]>([]);
const [search, setSearch] = useState('');
const [category, setCategory] = useState<string>('all');
useEffect(() => {
api.getServices().then(setServices).catch(() => setServices([]));
}, []);
const categories = useMemo(() => {
const cats = new Set(services.map((s) => s.category));
return ['all', ...Array.from(cats)];
}, [services]);
const filtered = useMemo(() => {
return services.filter((s) => {
if (category !== 'all' && s.category !== category) return false;
if (!search) return true;
const q = search.toLowerCase();
return (
s.title.toLowerCase().includes(q) ||
s.description.toLowerCase().includes(q) ||
s.system.toLowerCase().includes(q)
);
});
}, [services, search, category]);
return (
<div className="catalog">
<div className="catalog-header">
<div>
<h2 className="catalog-title">Каталог услуг</h2>
<p className="catalog-desc">
Прямой выбор услуги, если вы знаете, что нужно. Или опишите задачу на витрине
ассистент подберёт сценарий.
</p>
</div>
</div>
<div className="catalog-toolbar">
<div className="catalog-search">
<Search size={18} />
<input
placeholder="Поиск услуги..."
value={search}
onChange={(e) => setSearch(e.target.value)}
/>
</div>
<div className="catalog-filters">
{categories.map((cat) => (
<button
key={cat}
className={`filter-chip ${category === cat ? 'active' : ''}`}
onClick={() => setCategory(cat)}
>
{cat === 'all' ? 'Все категории' : cat}
</button>
))}
</div>
</div>
<div className="catalog-grid">
{filtered.map((service) => (
<div key={service.id} className="service-card">
<div className="service-card-header">
<span className="service-system">{service.system}</span>
<span className="service-category">{service.category}</span>
</div>
<h3 className="service-title">{service.title}</h3>
<p className="service-desc">{service.description}</p>
<div className="service-footer">
<span className="service-sla">
<Clock size={14} />
SLA: {service.sla}
</span>
<button
className="service-action"
onClick={() => onSelectService(`${service.title}${service.description}`)}
>
Спросить ассистента
<ArrowRight size={14} />
</button>
</div>
</div>
))}
</div>
{filtered.length === 0 && (
<div className="catalog-empty">Услуги не найдены. Попробуйте изменить фильтры.</div>
)}
</div>
);
}
+58
View File
@@ -0,0 +1,58 @@
.chat-history-modal {
max-width: 640px;
width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
}
.chat-history-hint {
margin: 0 0 16px;
font-size: 0.875rem;
color: var(--text-muted);
line-height: 1.5;
}
.chat-history-list {
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
padding-right: 4px;
}
.chat-history-empty {
color: var(--text-muted);
text-align: center;
padding: 24px;
}
.chat-history-item {
padding: 12px 14px;
border-radius: 10px;
background: var(--surface);
border: 1px solid var(--border);
}
.chat-history-item.user {
border-left: 3px solid var(--primary);
}
.chat-history-item.assistant {
border-left: 3px solid #94a3b8;
}
.chat-history-meta {
display: flex;
justify-content: space-between;
gap: 12px;
margin-bottom: 6px;
font-size: 0.8rem;
color: var(--text-muted);
}
.chat-history-item p {
margin: 0;
white-space: pre-wrap;
line-height: 1.5;
}
+49
View File
@@ -0,0 +1,49 @@
import { X } from 'lucide-react';
import type { ChatMessage } from '../types';
import './ChatHistoryModal.css';
interface ChatHistoryModalProps {
messages: ChatMessage[];
onClose: () => void;
}
export function ChatHistoryModal({ messages, onClose }: ChatHistoryModalProps) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal chat-history-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>История диалога</h3>
<button className="modal-close" onClick={onClose} aria-label="Закрыть">
<X size={20} />
</button>
</div>
<p className="chat-history-hint">
Переписка с ассистентом в текущей сессии. Для статусов заявок используйте вкладку
«История моих обращений».
</p>
<div className="chat-history-list">
{messages.length === 0 ? (
<p className="chat-history-empty">Диалог ещё не начат.</p>
) : (
messages.map((msg) => (
<div key={msg.id} className={`chat-history-item ${msg.role}`}>
<div className="chat-history-meta">
<strong>{msg.role === 'user' ? 'Вы' : 'Ассистент'}</strong>
<span>
{msg.timestamp.toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</span>
</div>
<p>{msg.text}</p>
</div>
))
)}
</div>
</div>
</div>
);
}
+55
View File
@@ -0,0 +1,55 @@
.chat-input-row {
display: flex;
gap: 12px;
align-items: flex-end;
max-width: 900px;
margin: 0 auto;
}
.chat-input {
flex: 1;
padding: 14px 18px;
border: 1px solid var(--border);
border-radius: var(--radius);
resize: none;
font-size: 0.95rem;
background: var(--surface);
color: var(--text);
line-height: 1.45;
min-height: 52px;
max-height: 120px;
transition: border-color 0.15s;
box-shadow: var(--shadow);
}
.chat-input::placeholder {
color: #aaa;
}
.chat-input:focus {
outline: none;
border-color: var(--primary);
}
.chat-send-btn {
width: 52px;
height: 52px;
flex-shrink: 0;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.chat-send-btn:hover:not(:disabled) {
background: var(--primary-hover);
}
.chat-send-btn:disabled {
opacity: 0.4;
cursor: not-allowed;
}
+48
View File
@@ -0,0 +1,48 @@
import { Send } from 'lucide-react';
import './ChatInputRow.css';
interface ChatInputRowProps {
value: string;
onChange: (value: string) => void;
onSend: () => void;
onKeyDown?: (e: React.KeyboardEvent<HTMLTextAreaElement>) => void;
disabled?: boolean;
inputRef?: React.RefObject<HTMLTextAreaElement | null>;
placeholder?: string;
className?: string;
}
export function ChatInputRow({
value,
onChange,
onSend,
onKeyDown,
disabled = false,
inputRef,
placeholder = 'Опишите проблему... (Enter — отправить, Shift+Enter — новая строка)',
className,
}: ChatInputRowProps) {
return (
<div className={`chat-input-row ${className ?? ''}`.trim()}>
<textarea
ref={inputRef}
className="chat-input"
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={onKeyDown}
rows={1}
disabled={disabled}
/>
<button
type="button"
className="chat-send-btn"
onClick={onSend}
disabled={!value.trim() || disabled}
aria-label="Отправить"
>
<Send size={20} />
</button>
</div>
);
}
+160
View File
@@ -0,0 +1,160 @@
.header {
background: var(--surface);
border-bottom: 1px solid var(--border);
height: var(--header-height);
flex-shrink: 0;
}
.header-inner {
max-width: 1400px;
height: 100%;
margin: 0 auto;
padding: 0 32px;
display: grid;
grid-template-columns: 1fr auto 1fr;
align-items: center;
gap: 24px;
}
.header-brand {
margin: 0;
font-size: 1.35rem;
font-weight: 700;
color: var(--primary);
letter-spacing: -0.01em;
justify-self: start;
}
.header-nav {
display: flex;
align-items: center;
gap: 28px;
justify-self: center;
}
.header-nav-item {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 4px;
border: none;
background: none;
color: var(--text-muted);
font-size: 0.95rem;
font-weight: 500;
border-bottom: 2px solid transparent;
transition: color 0.15s, border-color 0.15s;
}
.header-nav-item svg {
flex-shrink: 0;
}
.header-nav-item:hover {
color: var(--text);
}
.header-nav-item.active {
color: var(--primary);
border-bottom-color: var(--primary);
}
.header-actions {
display: flex;
align-items: center;
gap: 12px;
justify-self: end;
}
.header-admin-btn {
padding: 8px 18px;
border: 1.5px solid var(--primary);
border-radius: 24px;
background: transparent;
color: var(--primary);
font-size: 0.9rem;
font-weight: 500;
transition: background 0.15s, color 0.15s;
}
.header-admin-btn:hover {
background: var(--primary-light);
}
.header-icon-btn {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: transparent;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s, color 0.15s;
}
.header-icon-btn:hover {
background: var(--surface-2);
color: var(--text);
}
.header-avatar {
width: 40px;
height: 40px;
border: none;
border-radius: 50%;
background: #e8e8e8;
color: var(--text-muted);
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
}
.header-avatar:hover {
background: #ddd;
}
@media (max-width: 900px) {
.header-inner {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
height: auto;
padding: 12px 16px;
gap: 12px;
}
.header-brand {
grid-column: 1;
grid-row: 1;
font-size: 1.15rem;
}
.header-actions {
grid-column: 2;
grid-row: 1;
}
.header-nav {
grid-column: 1 / -1;
grid-row: 2;
justify-self: start;
gap: 16px;
overflow-x: auto;
}
.header-nav-item span {
white-space: nowrap;
}
.header-admin-btn {
display: none;
}
}
@media (max-width: 480px) {
.header-nav-item span {
font-size: 0.85rem;
}
}
+56
View File
@@ -0,0 +1,56 @@
import { ClipboardList, LayoutGrid, MessageCircle } from 'lucide-react';
import type { Appeal, TabId } from '../types';
import { NotificationsPanel } from './NotificationsPanel';
import { ProfilePanel } from './ProfilePanel';
import './Header.css';
interface HeaderProps {
activeTab: TabId;
onTabChange: (tab: TabId) => void;
appeals: Appeal[];
}
export function Header({ activeTab, onTabChange, appeals }: HeaderProps) {
return (
<header className="header">
<div className="header-inner">
<h1 className="header-brand">ИТ-Ассистент</h1>
<nav className="header-nav">
<button
type="button"
className={`header-nav-item ${activeTab === 'vitrina' ? 'active' : ''}`}
onClick={() => onTabChange('vitrina')}
>
<MessageCircle size={18} strokeWidth={2} />
<span>Чат</span>
</button>
<button
type="button"
className={`header-nav-item ${activeTab === 'appeals' ? 'active' : ''}`}
onClick={() => onTabChange('appeals')}
>
<ClipboardList size={18} strokeWidth={2} />
<span>История моих обращений</span>
</button>
<button
type="button"
className={`header-nav-item ${activeTab === 'catalog' ? 'active' : ''}`}
onClick={() => onTabChange('catalog')}
>
<LayoutGrid size={18} strokeWidth={2} />
<span>Каталог услуг</span>
</button>
</nav>
<div className="header-actions">
<button type="button" className="header-admin-btn">
Администратор
</button>
<NotificationsPanel onNavigate={onTabChange} />
<ProfilePanel appeals={appeals} onNavigate={onTabChange} />
</div>
</div>
</header>
);
}
+132
View File
@@ -0,0 +1,132 @@
.inline-ticket {
margin-top: 12px;
padding: 14px;
background: var(--surface-2);
border: 1px solid var(--border);
border-radius: 12px;
}
.inline-ticket-hint {
margin: 0 0 12px;
font-size: 0.82rem;
color: var(--primary);
background: var(--primary-light);
padding: 8px 10px;
border-radius: 8px;
}
.inline-ticket-error {
margin: 0 0 10px;
font-size: 0.82rem;
color: #b91c1c;
background: #fef2f2;
padding: 8px 10px;
border-radius: 8px;
}
.inline-ticket-form {
display: flex;
flex-direction: column;
gap: 10px;
}
.inline-ticket-form label {
display: flex;
flex-direction: column;
gap: 4px;
font-size: 0.8rem;
font-weight: 500;
color: var(--text-muted);
}
.inline-ticket-form input,
.inline-ticket-form select,
.inline-ticket-form textarea {
padding: 9px 11px;
border: 1px solid var(--border);
border-radius: 8px;
font-size: 0.88rem;
background: #fff;
color: var(--text);
}
.inline-ticket-form input.readonly {
background: var(--surface);
color: var(--text-muted);
}
.inline-ticket-form input:focus,
.inline-ticket-form select:focus,
.inline-ticket-form textarea:focus {
outline: none;
border-color: var(--primary);
}
.inline-ticket-actions {
display: flex;
justify-content: flex-end;
gap: 8px;
margin-top: 12px;
}
.inline-ticket-cancel {
padding: 9px 14px;
border: 1px solid var(--border);
border-radius: 8px;
background: #fff;
font-size: 0.85rem;
cursor: pointer;
}
.inline-ticket-submit {
padding: 9px 16px;
border: none;
border-radius: 8px;
background: var(--primary);
color: #fff;
font-size: 0.85rem;
font-weight: 600;
cursor: pointer;
}
.inline-ticket-submit:hover:not(:disabled) {
background: var(--primary-hover);
}
.inline-ticket-submit:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.inline-ticket--success {
display: flex;
align-items: center;
gap: 12px;
background: #ecfdf5;
border-color: #a7f3d0;
}
.inline-ticket-success-icon {
color: #059669;
flex-shrink: 0;
}
.inline-ticket--success p {
margin: 4px 0 0;
font-size: 0.82rem;
color: var(--text-muted);
}
.inline-ticket-link-btn {
margin-left: auto;
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
color: var(--primary);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
}
+121
View File
@@ -0,0 +1,121 @@
import { useState } from 'react';
import { CheckCircle, ArrowRight } from 'lucide-react';
import { api } from '../api/client';
import type { Appeal, TicketDraft } from '../types';
import './InlineTicketForm.css';
interface InlineTicketFormProps {
draft: TicketDraft;
system?: string;
onCreated: (appeal: Appeal) => void;
onCancel?: () => void;
onGoToAppeals?: () => void;
}
export function InlineTicketForm({
draft,
system,
onCreated,
onCancel,
onGoToAppeals,
}: InlineTicketFormProps) {
const [title, setTitle] = useState(draft.title);
const [description, setDescription] = useState(draft.description);
const [priority, setPriority] = useState(draft.priority);
const [isSubmitting, setIsSubmitting] = useState(false);
const [created, setCreated] = useState<Appeal | null>(null);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
try {
const appeal = await api.createAppeal({
title,
description,
system: draft.system ?? system ?? 'Общее',
priority,
});
setCreated(appeal);
onCreated(appeal);
} catch {
setError('Не удалось создать обращение. Проверьте подключение к серверу.');
} finally {
setIsSubmitting(false);
}
};
if (created) {
return (
<div className="inline-ticket inline-ticket--success">
<CheckCircle size={22} className="inline-ticket-success-icon" />
<div>
<strong>Обращение {created.number} создано</strong>
<p>
Ориентировочный срок:{' '}
{created.priority === 'critical' || created.priority === 'high'
? '4 часа'
: '1 рабочий день'}
</p>
</div>
{onGoToAppeals && (
<button type="button" className="inline-ticket-link-btn" onClick={onGoToAppeals}>
К обращениям
<ArrowRight size={14} />
</button>
)}
</div>
);
}
return (
<div className="inline-ticket">
<p className="inline-ticket-hint">
Поля предзаполнены из диалога. Проверьте данные перед отправкой в поддержку.
</p>
{error && <p className="inline-ticket-error">{error}</p>}
<div className="inline-ticket-form">
<label>
Тема
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
Система
<input value={draft.system ?? system ?? ''} readOnly className="readonly" />
</label>
<label>
Приоритет
<select value={priority} onChange={(e) => setPriority(e.target.value)}>
<option>Критический</option>
<option>Высокий</option>
<option>Средний</option>
<option>Низкий</option>
</select>
</label>
<label>
Описание
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</label>
</div>
<div className="inline-ticket-actions">
{onCancel && (
<button type="button" className="inline-ticket-cancel" onClick={onCancel}>
Отмена
</button>
)}
<button
type="button"
className="inline-ticket-submit"
onClick={handleSubmit}
disabled={isSubmitting || !title.trim()}
>
{isSubmitting ? 'Создание...' : 'Создать обращение'}
</button>
</div>
</div>
);
}
+103
View File
@@ -0,0 +1,103 @@
.notifications-wrap {
position: relative;
}
.notifications-btn {
position: relative;
}
.notifications-badge {
position: absolute;
top: 4px;
right: 4px;
min-width: 16px;
height: 16px;
padding: 0 4px;
border-radius: 999px;
background: #ef4444;
color: #fff;
font-size: 10px;
font-weight: 700;
line-height: 16px;
text-align: center;
}
.notifications-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 340px;
max-height: 420px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
z-index: 100;
display: flex;
flex-direction: column;
}
.notifications-panel-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 12px 14px;
border-bottom: 1px solid var(--border);
}
.notifications-mark-all {
border: none;
background: none;
color: var(--primary);
font-size: 0.8rem;
cursor: pointer;
}
.notifications-list {
overflow-y: auto;
padding: 8px;
display: flex;
flex-direction: column;
gap: 6px;
}
.notifications-empty {
padding: 20px;
text-align: center;
color: var(--text-muted);
font-size: 0.875rem;
}
.notification-item {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 4px;
width: 100%;
text-align: left;
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: 10px;
background: #fff;
cursor: pointer;
}
.notification-item.unread {
background: #f0fdff;
border-color: #b8eef3;
}
.notification-item strong {
font-size: 0.875rem;
}
.notification-item span {
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
.notification-item time {
font-size: 0.75rem;
color: #94a3b8;
}
+116
View File
@@ -0,0 +1,116 @@
import { useEffect, useRef, useState } from 'react';
import { Bell } from 'lucide-react';
import { api } from '../api/client';
import type { NotificationItem, TabId } from '../types';
import './NotificationsPanel.css';
interface NotificationsPanelProps {
onNavigate?: (tab: TabId) => void;
}
export function NotificationsPanel({ onNavigate }: NotificationsPanelProps) {
const [open, setOpen] = useState(false);
const [items, setItems] = useState<NotificationItem[]>([]);
const [unread, setUnread] = useState(0);
const panelRef = useRef<HTMLDivElement>(null);
const load = async () => {
try {
const [list, countData] = await Promise.all([
api.getNotifications(),
api.getUnreadNotificationsCount(),
]);
setItems(list);
setUnread(countData.count);
} catch {
/* backend offline */
}
};
useEffect(() => {
load();
const timer = window.setInterval(load, 30000);
return () => window.clearInterval(timer);
}, []);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [open]);
const handleOpen = () => {
setOpen((v) => !v);
if (!open) load();
};
const handleItemClick = async (item: NotificationItem) => {
await api.markNotificationRead(item.id).catch(() => {});
setItems((prev) => prev.map((n) => (n.id === item.id ? { ...n, read: true } : n)));
setUnread((c) => Math.max(0, c - (item.read ? 0 : 1)));
if (item.linkTab && onNavigate) onNavigate(item.linkTab);
setOpen(false);
};
const handleMarkAll = async () => {
await api.markAllNotificationsRead().catch(() => {});
setItems((prev) => prev.map((n) => ({ ...n, read: true })));
setUnread(0);
};
return (
<div className="notifications-wrap" ref={panelRef}>
<button
type="button"
className="header-icon-btn notifications-btn"
aria-label="Уведомления"
onClick={handleOpen}
>
<Bell size={20} strokeWidth={1.8} />
{unread > 0 && <span className="notifications-badge">{unread}</span>}
</button>
{open && (
<div className="notifications-panel">
<div className="notifications-panel-header">
<strong>Уведомления</strong>
{unread > 0 && (
<button type="button" className="notifications-mark-all" onClick={handleMarkAll}>
Прочитать все
</button>
)}
</div>
<div className="notifications-list">
{items.length === 0 ? (
<p className="notifications-empty">Нет уведомлений</p>
) : (
items.map((item) => (
<button
key={item.id}
type="button"
className={`notification-item ${item.read ? 'read' : 'unread'}`}
onClick={() => handleItemClick(item)}
>
<strong>{item.title}</strong>
<span>{item.text}</span>
<time>
{new Date(item.createdAt).toLocaleString('ru-RU', {
day: '2-digit',
month: '2-digit',
hour: '2-digit',
minute: '2-digit',
})}
</time>
</button>
))
)}
</div>
</div>
)}
</div>
);
}
+196
View File
@@ -0,0 +1,196 @@
.proactive-home {
flex: 1;
overflow-y: auto;
padding: 28px 32px 24px;
max-width: 920px;
width: 100%;
margin: 0 auto;
}
.proactive-hero {
display: flex;
gap: 16px;
align-items: flex-start;
margin-bottom: 28px;
}
.proactive-hero-icon {
width: 56px;
height: 56px;
border-radius: 16px;
background: var(--primary-light);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
}
.proactive-hero-title {
margin: 0 0 8px;
font-size: 1.5rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
}
.proactive-hero-subtitle {
margin: 0;
font-size: 0.95rem;
color: var(--text-muted);
line-height: 1.55;
max-width: 640px;
}
.proactive-hero-input {
margin-bottom: 28px;
}
.proactive-hero-input .chat-input-row {
max-width: none;
}
.proactive-input-hint {
margin: 8px 0 0;
font-size: 0.8rem;
color: var(--text-muted);
padding-left: 4px;
}
.proactive-section {
margin-bottom: 28px;
}
.proactive-section-title {
display: flex;
align-items: center;
gap: 8px;
margin: 0 0 14px;
font-size: 0.9rem;
font-weight: 600;
color: var(--text);
text-transform: uppercase;
letter-spacing: 0.03em;
}
.proactive-section-title svg {
color: var(--primary);
}
.attention-grid {
display: flex;
flex-direction: column;
gap: 10px;
}
.attention-card {
display: grid;
grid-template-columns: auto 1fr auto;
gap: 12px;
align-items: center;
padding: 14px 16px;
background: var(--surface);
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: var(--shadow);
}
.attention-card--appeal {
border-left: 3px solid #f59e0b;
}
.attention-card--notification {
border-left: 3px solid var(--primary);
}
.attention-card-icon {
width: 36px;
height: 36px;
border-radius: 10px;
background: var(--surface-2);
color: var(--primary);
display: flex;
align-items: center;
justify-content: center;
}
.attention-card-body strong {
display: block;
font-size: 0.9rem;
margin-bottom: 4px;
}
.attention-card-body p {
margin: 0;
font-size: 0.82rem;
color: var(--text-muted);
line-height: 1.4;
}
.attention-card-action {
display: inline-flex;
align-items: center;
gap: 4px;
border: none;
background: none;
color: var(--primary);
font-size: 0.82rem;
font-weight: 600;
cursor: pointer;
white-space: nowrap;
padding: 6px 8px;
border-radius: 8px;
transition: background 0.15s;
}
.attention-card-action:hover {
background: var(--primary-light);
}
.frequent-actions {
display: flex;
flex-wrap: wrap;
gap: 10px;
}
.frequent-action-chip {
padding: 10px 18px;
border: 1.5px solid var(--primary);
border-radius: 24px;
background: transparent;
color: var(--primary);
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: background 0.15s, color 0.15s;
}
.frequent-action-chip:hover:not(:disabled) {
background: var(--primary-light);
}
.frequent-action-chip:disabled {
opacity: 0.5;
cursor: not-allowed;
}
@media (max-width: 768px) {
.proactive-home {
padding: 20px 16px;
}
.proactive-hero {
flex-direction: column;
}
.attention-card {
grid-template-columns: auto 1fr;
grid-template-rows: auto auto;
}
.attention-card-action {
grid-column: 1 / -1;
justify-self: start;
margin-left: 48px;
}
}
+218
View File
@@ -0,0 +1,218 @@
import { useEffect, useMemo, useState, type KeyboardEvent, type RefObject } from 'react';
import {
AlertCircle,
ArrowRight,
Bell,
ClipboardList,
Sparkles,
} from 'lucide-react';
import { api } from '../api/client';
import type { Appeal, NotificationItem, UserProfile } from '../types';
import { ChatInputRow } from './ChatInputRow';
import './ProactiveHome.css';
interface AttentionItemData {
id: string;
kind: 'appeal' | 'notification';
title: string;
subtitle: string;
actionLabel: string;
notificationId?: string;
linkTab?: NotificationItem['linkTab'];
}
function buildAttentionItems(
appeals: Appeal[],
notifications: NotificationItem[],
): AttentionItemData[] {
const items: AttentionItemData[] = [];
appeals
.filter((a) => a.status === 'waiting' || a.status === 'new')
.slice(0, 2)
.forEach((appeal) => {
items.push({
id: `appeal-${appeal.id}`,
kind: 'appeal',
title:
appeal.status === 'waiting'
? `Требуется ваш ответ: ${appeal.number}`
: `Новое обращение: ${appeal.number}`,
subtitle: appeal.nextStep,
actionLabel: 'Открыть обращение',
});
});
notifications
.filter((n) => !n.read)
.slice(0, 2)
.forEach((note) => {
items.push({
id: `ntf-${note.id}`,
kind: 'notification',
title: note.title,
subtitle: note.text,
actionLabel: 'Подробнее',
notificationId: note.id,
linkTab: note.linkTab,
});
});
return items.slice(0, 4);
}
function personalizePrompts(prompts: string[], profile: UserProfile | null): string[] {
const base = [...prompts];
if (!profile) return base;
const dept = profile.department.toLowerCase();
if (dept.includes('нефтехим') || dept.includes('надёжност')) {
const meridium = 'Ошибка в Meridium';
if (!base.includes(meridium)) base.unshift(meridium);
}
return [...new Set(base)].slice(0, 6);
}
interface ProactiveHomeProps {
appeals: Appeal[];
prompts: string[];
isTyping: boolean;
input: string;
inputRef: RefObject<HTMLTextAreaElement | null>;
onInputChange: (value: string) => void;
onSend: () => void;
onKeyDown: (e: KeyboardEvent) => void;
onSendPrompt: (text: string) => void;
onGoToAppeals: () => void;
}
export function ProactiveHome({
appeals,
prompts,
isTyping,
input,
inputRef,
onInputChange,
onSend,
onKeyDown,
onSendPrompt,
onGoToAppeals,
}: ProactiveHomeProps) {
const [profile, setProfile] = useState<UserProfile | null>(null);
const [notifications, setNotifications] = useState<NotificationItem[]>([]);
useEffect(() => {
api.getProfile().then(setProfile).catch(() => {});
api.getNotifications().then(setNotifications).catch(() => {});
}, []);
const attentionItems = useMemo(
() => buildAttentionItems(appeals, notifications),
[appeals, notifications],
);
const handleAttentionAction = async (item: AttentionItemData) => {
if (item.kind === 'appeal') {
onGoToAppeals();
return;
}
if (item.notificationId) {
await api.markNotificationRead(item.notificationId).catch(() => {});
setNotifications((prev) =>
prev.map((n) => (n.id === item.notificationId ? { ...n, read: true } : n)),
);
if (item.linkTab === 'appeals') onGoToAppeals();
}
};
const frequentActions = useMemo(
() => personalizePrompts(prompts, profile),
[prompts, profile],
);
const greeting = profile
? `${profile.firstName} ${profile.patronymic}, чем могу помочь?`
: 'Мы здесь, чтобы помочь вам';
return (
<div className="proactive-home animate-in">
<header className="proactive-hero">
<div className="proactive-hero-icon">
<Sparkles size={28} strokeWidth={1.5} />
</div>
<div>
<h2 className="proactive-hero-title">{greeting}</h2>
<p className="proactive-hero-subtitle">
Подсказки собраны по вашим обращениям и типовым сценариям. Опишите задачу
в поле ниже или выберите частое действие.
</p>
</div>
</header>
<div className="proactive-hero-input">
<ChatInputRow
value={input}
onChange={onInputChange}
onSend={onSend}
onKeyDown={onKeyDown}
disabled={isTyping}
inputRef={inputRef}
placeholder="Опишите задачу своими словами..."
/>
<p className="proactive-input-hint">Enter отправить, Shift+Enter новая строка</p>
</div>
{attentionItems.length > 0 && (
<section className="proactive-section">
<h3 className="proactive-section-title">
<AlertCircle size={18} />
Требует внимания
</h3>
<div className="attention-grid">
{attentionItems.map((item) => (
<article key={item.id} className={`attention-card attention-card--${item.kind}`}>
<div className="attention-card-icon">
{item.kind === 'appeal' ? (
<ClipboardList size={18} />
) : (
<Bell size={18} />
)}
</div>
<div className="attention-card-body">
<strong>{item.title}</strong>
<p>{item.subtitle}</p>
</div>
<button
type="button"
className="attention-card-action"
onClick={() => handleAttentionAction(item)}
>
{item.actionLabel}
<ArrowRight size={14} />
</button>
</article>
))}
</div>
</section>
)}
<section className="proactive-section">
<h3 className="proactive-section-title">Частые действия</h3>
<div className="frequent-actions">
{frequentActions.map((prompt) => (
<button
key={prompt}
type="button"
className="frequent-action-chip"
onClick={() => onSendPrompt(prompt)}
disabled={isTyping}
>
{prompt}
</button>
))}
</div>
</section>
</div>
);
}
+144
View File
@@ -0,0 +1,144 @@
.profile-wrap {
position: relative;
}
.profile-trigger {
cursor: pointer;
}
.profile-trigger.active {
background: var(--primary-light);
color: var(--primary);
}
.profile-initials {
font-size: 0.8rem;
font-weight: 700;
letter-spacing: 0.02em;
}
.profile-panel {
position: absolute;
top: calc(100% + 8px);
right: 0;
width: 320px;
background: #fff;
border: 1px solid var(--border);
border-radius: 12px;
box-shadow: 0 12px 32px rgba(15, 23, 42, 0.12);
z-index: 100;
padding: 16px;
}
.profile-panel-header {
display: flex;
gap: 12px;
margin-bottom: 16px;
padding-bottom: 14px;
border-bottom: 1px solid var(--border);
}
.profile-avatar-large {
width: 48px;
height: 48px;
border-radius: 50%;
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
font-size: 1rem;
font-weight: 700;
flex-shrink: 0;
}
.profile-name {
display: block;
font-size: 0.95rem;
line-height: 1.35;
margin-bottom: 6px;
}
.profile-meta {
display: flex;
align-items: flex-start;
gap: 6px;
margin: 0 0 4px;
font-size: 0.8rem;
color: var(--text-muted);
line-height: 1.4;
}
.profile-meta svg {
flex-shrink: 0;
margin-top: 2px;
color: var(--primary);
}
.profile-stats {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin-bottom: 14px;
}
.profile-stat {
flex: 1;
min-width: 80px;
background: var(--surface-2);
border-radius: 10px;
padding: 10px 12px;
text-align: center;
}
.profile-stat.highlight {
background: var(--primary-light);
}
.profile-stat-value {
display: block;
font-size: 1.25rem;
font-weight: 700;
color: var(--text);
line-height: 1.2;
}
.profile-stat.highlight .profile-stat-value {
color: var(--primary);
}
.profile-stat-label {
display: block;
font-size: 0.72rem;
color: var(--text-muted);
margin-top: 2px;
}
.profile-action-btn {
width: 100%;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
padding: 10px 14px;
border: none;
border-radius: 10px;
background: var(--primary);
color: #fff;
font-size: 0.88rem;
font-weight: 600;
cursor: pointer;
transition: background 0.15s;
}
.profile-action-btn:hover {
background: var(--primary-hover);
}
.profile-footnote {
margin: 12px 0 0;
font-size: 0.72rem;
color: var(--text-muted);
line-height: 1.45;
text-align: center;
}
+122
View File
@@ -0,0 +1,122 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { ArrowRight, Building2, Briefcase, User } from 'lucide-react';
import { api } from '../api/client';
import type { Appeal, TabId, UserProfile } from '../types';
import './ProfilePanel.css';
interface ProfilePanelProps {
appeals: Appeal[];
onNavigate?: (tab: TabId) => void;
}
function getInitials(profile: UserProfile): string {
const first = profile.firstName?.[0] ?? '';
const last = profile.lastName?.[0] ?? '';
return `${first}${last}`.toUpperCase() || '?';
}
export function ProfilePanel({ appeals, onNavigate }: ProfilePanelProps) {
const [open, setOpen] = useState(false);
const [profile, setProfile] = useState<UserProfile | null>(null);
const panelRef = useRef<HTMLDivElement>(null);
const stats = useMemo(() => {
const counts = { total: appeals.length, inProgress: 0, new: 0, waiting: 0 };
appeals.forEach((a) => {
if (a.status === 'in_progress') counts.inProgress++;
if (a.status === 'new') counts.new++;
if (a.status === 'waiting') counts.waiting++;
});
return counts;
}, [appeals]);
useEffect(() => {
api.getProfile().then(setProfile).catch(() => {});
}, []);
useEffect(() => {
const onClickOutside = (e: MouseEvent) => {
if (panelRef.current && !panelRef.current.contains(e.target as Node)) {
setOpen(false);
}
};
if (open) document.addEventListener('mousedown', onClickOutside);
return () => document.removeEventListener('mousedown', onClickOutside);
}, [open]);
const fullName = profile
? `${profile.firstName} ${profile.patronymic} ${profile.lastName}`
: 'Загрузка…';
const activeAppeals = stats.inProgress + stats.new + stats.waiting;
return (
<div className="profile-wrap" ref={panelRef}>
<button
type="button"
className={`header-avatar profile-trigger ${open ? 'active' : ''}`}
aria-label="Профиль"
aria-expanded={open}
onClick={() => setOpen((v) => !v)}
>
{profile ? (
<span className="profile-initials">{getInitials(profile)}</span>
) : (
<User size={20} strokeWidth={1.8} />
)}
</button>
{open && profile && (
<div className="profile-panel">
<div className="profile-panel-header">
<div className="profile-avatar-large">{getInitials(profile)}</div>
<div>
<strong className="profile-name">{fullName}</strong>
<p className="profile-meta">
<Briefcase size={14} />
{profile.position}
</p>
<p className="profile-meta">
<Building2 size={14} />
{profile.department}
</p>
</div>
</div>
<div className="profile-stats">
<div className="profile-stat">
<span className="profile-stat-value">{stats.total}</span>
<span className="profile-stat-label">всего обращений</span>
</div>
<div className="profile-stat highlight">
<span className="profile-stat-value">{activeAppeals}</span>
<span className="profile-stat-label">активных</span>
</div>
{stats.inProgress > 0 && (
<div className="profile-stat">
<span className="profile-stat-value">{stats.inProgress}</span>
<span className="profile-stat-label">в работе</span>
</div>
)}
</div>
<button
type="button"
className="profile-action-btn"
onClick={() => {
onNavigate?.('appeals');
setOpen(false);
}}
>
Перейти к обращениям
<ArrowRight size={16} />
</button>
<p className="profile-footnote">
Данные из корпоративного каталога. Изменение через HR или ИТ-поддержку.
</p>
</div>
)}
</div>
);
}
+293
View File
@@ -0,0 +1,293 @@
.response-package {
background: var(--surface);
border: 1px solid var(--border);
border-radius: var(--radius);
padding: 16px;
margin-top: 4px;
box-shadow: var(--shadow);
}
.response-package--escalation {
border-color: #f59e0b;
background: #fffdf8;
}
.rp-header {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
margin-bottom: 14px;
padding-bottom: 10px;
border-bottom: 1px solid var(--border);
}
.rp-label {
font-size: 0.75rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--text-muted);
}
.rp-system {
background: var(--primary-light);
color: var(--primary);
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
}
.rp-confidence {
margin-left: auto;
font-size: 0.75rem;
font-weight: 600;
padding: 3px 10px;
border-radius: 12px;
}
.rp-confidence.high {
background: var(--primary-light);
color: var(--primary);
}
.rp-confidence.medium {
background: var(--warning-bg);
color: var(--warning);
}
.rp-confidence.low {
background: #fef2f2;
color: var(--danger);
}
.rp-section {
margin-bottom: 14px;
}
.rp-section:last-of-type {
margin-bottom: 0;
}
.rp-section-title {
display: flex;
align-items: center;
gap: 6px;
font-size: 0.82rem;
font-weight: 600;
color: var(--text);
margin-bottom: 8px;
}
.rp-section-title svg {
color: var(--primary);
}
.rp-text {
margin: 0;
font-size: 0.9rem;
line-height: 1.55;
}
.rp-steps {
margin: 0;
padding-left: 20px;
font-size: 0.88rem;
}
.rp-steps li {
margin-bottom: 6px;
line-height: 1.45;
}
.rp-sources {
margin: 0;
padding-left: 18px;
font-size: 0.88rem;
}
.rp-sources li {
margin-bottom: 4px;
}
.rp-cases {
display: flex;
flex-direction: column;
gap: 8px;
}
.rp-case {
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 0.85rem;
line-height: 1.45;
}
.rp-case strong {
display: block;
margin-bottom: 2px;
}
.rp-case span {
color: var(--text-muted);
}
.rp-warning {
display: flex;
gap: 8px;
align-items: flex-start;
background: var(--warning-bg);
border: 1px solid #fcd34d;
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 0.85rem;
color: #92400e;
margin-bottom: 14px;
}
.rp-warning svg {
flex-shrink: 0;
margin-top: 2px;
}
.rp-why {
display: flex;
gap: 8px;
align-items: flex-start;
background: #f0f9ff;
border: 1px solid #bae6fd;
border-radius: var(--radius-sm);
padding: 10px 12px;
font-size: 0.84rem;
color: #0c4a6e;
margin-bottom: 14px;
line-height: 1.45;
}
.rp-why svg {
flex-shrink: 0;
margin-top: 2px;
color: #0284c7;
}
.rp-quick-actions {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-bottom: 14px;
}
.rp-quick-action {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: 20px;
border: 1.5px solid var(--primary);
background: #fff;
color: var(--primary);
font-size: 0.82rem;
font-weight: 600;
text-decoration: none;
cursor: pointer;
transition: background 0.15s;
}
.rp-quick-action:hover {
background: var(--primary-light);
}
.rp-draft {
background: var(--surface-2);
border-radius: var(--radius-sm);
padding: 12px;
}
.rp-draft--escalation {
border: 1px solid #f59e0b;
background: #fffbeb;
margin-bottom: 14px;
}
.rp-draft--escalation .rp-section-title {
color: #b45309;
}
.rp-draft-content {
font-size: 0.85rem;
margin-bottom: 10px;
}
.rp-draft-content div {
margin-bottom: 4px;
}
.rp-draft-desc {
margin: 8px 0 0;
color: var(--text-muted);
font-size: 0.82rem;
}
.rp-ticket-btn {
width: 100%;
padding: 10px;
border: none;
border-radius: var(--radius-sm);
background: var(--primary);
color: #fff;
font-size: 0.88rem;
font-weight: 600;
transition: background 0.15s;
}
.rp-ticket-btn:hover {
background: var(--primary-hover);
}
.rp-feedback {
margin-top: 14px;
padding-top: 14px;
border-top: 1px solid var(--border);
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.rp-feedback-label {
font-size: 0.85rem;
font-weight: 500;
}
.rp-feedback-btns {
display: flex;
gap: 8px;
}
.rp-feedback-yes,
.rp-feedback-no {
display: flex;
align-items: center;
gap: 6px;
padding: 8px 14px;
border-radius: var(--radius-sm);
font-size: 0.85rem;
font-weight: 500;
border: 1px solid var(--border);
background: var(--surface);
transition: all 0.15s;
}
.rp-feedback-yes:hover {
border-color: var(--primary);
background: var(--primary-light);
color: var(--primary);
}
.rp-feedback-no:hover {
border-color: var(--danger);
background: #fef2f2;
color: var(--danger);
}
+235
View File
@@ -0,0 +1,235 @@
import {
BookOpen,
ListChecks,
Users,
AlertTriangle,
FileText,
ThumbsUp,
ThumbsDown,
ExternalLink,
Lightbulb,
Headphones,
} from 'lucide-react';
import { CONFIDENCE_THRESHOLD, type Appeal, type ResponsePackage, type TicketDraft } from '../types';
import { InlineTicketForm } from './InlineTicketForm';
import './ResponsePackageCard.css';
interface ResponsePackageCardProps {
pkg: ResponsePackage;
ticketDraft?: TicketDraft;
showFeedback: boolean;
revealDraft?: boolean;
onFeedback: (helped: boolean) => void;
onRevealTicket: () => void;
onAppealCreated: (appeal: Appeal) => void;
onGoToAppeals?: () => void;
onCancelTicket?: () => void;
}
function TicketDraftSection({
draft,
system,
isLowConfidence,
onAppealCreated,
onCancelTicket,
onGoToAppeals,
}: {
draft: TicketDraft;
system?: string;
isLowConfidence: boolean;
onAppealCreated: (appeal: Appeal) => void;
onCancelTicket?: () => void;
onGoToAppeals?: () => void;
}) {
return (
<section
className={`rp-section rp-draft ${isLowConfidence ? 'rp-draft--escalation' : ''}`}
>
<div className="rp-section-title">
<FileText size={16} />
<span>
{isLowConfidence
? 'Черновик заявки готов к отправке'
: 'Черновик заявки'}
</span>
</div>
<InlineTicketForm
draft={draft}
system={system}
onCreated={onAppealCreated}
onCancel={onCancelTicket}
onGoToAppeals={onGoToAppeals}
/>
</section>
);
}
export function ResponsePackageCard({
pkg,
ticketDraft,
showFeedback,
revealDraft = false,
onFeedback,
onRevealTicket,
onAppealCreated,
onGoToAppeals,
onCancelTicket,
}: ResponsePackageCardProps) {
const isLowConfidence = pkg.confidence < CONFIDENCE_THRESHOLD;
const activeDraft = ticketDraft ?? pkg.ticketDraft;
const showTicketDraft = Boolean(activeDraft) && revealDraft;
const showFeedbackBlock = showFeedback && !isLowConfidence;
const primarySource = pkg.sources.find((s) => s.url);
const confidenceColor =
pkg.confidence >= CONFIDENCE_THRESHOLD
? 'high'
: pkg.confidence >= 50
? 'medium'
: 'low';
const draftBlock =
showTicketDraft && activeDraft ? (
<TicketDraftSection
draft={activeDraft}
system={pkg.system}
isLowConfidence={isLowConfidence}
onAppealCreated={onAppealCreated}
onCancelTicket={onCancelTicket}
onGoToAppeals={onGoToAppeals}
/>
) : null;
return (
<div className={`response-package animate-in ${isLowConfidence ? 'response-package--escalation' : ''}`}>
<div className="rp-header">
<span className="rp-label">
{isLowConfidence ? 'Эскалация в поддержку' : 'Пакет ответа'}
</span>
{pkg.system && <span className="rp-system">{pkg.system}</span>}
<span className={`rp-confidence ${confidenceColor}`}>
Уверенность: {pkg.confidence}%
</span>
</div>
{pkg.whySuggested && (
<div className="rp-why">
<Lightbulb size={16} />
<span>{pkg.whySuggested}</span>
</div>
)}
{isLowConfidence && draftBlock}
{!isLowConfidence && pkg.recommendation && (
<section className="rp-section">
<div className="rp-section-title">
<BookOpen size={16} />
<span>Рекомендация</span>
</div>
<p className="rp-text">{pkg.recommendation}</p>
</section>
)}
{!isLowConfidence && pkg.steps.length > 0 && (
<section className="rp-section">
<div className="rp-section-title">
<ListChecks size={16} />
<span>Пошаговые действия</span>
</div>
<ol className="rp-steps">
{pkg.steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</section>
)}
{!isLowConfidence && (primarySource || pkg.ticketDraft) && !showTicketDraft && (
<div className="rp-quick-actions">
{primarySource?.url && (
<a
className="rp-quick-action"
href={primarySource.url}
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink size={14} />
Открыть инструкцию
</a>
)}
{pkg.ticketDraft && (
<button type="button" className="rp-quick-action" onClick={onRevealTicket}>
<Headphones size={14} />
Обратиться в поддержку
</button>
)}
</div>
)}
{!isLowConfidence && pkg.sources.length > 0 && (
<section className="rp-section">
<div className="rp-section-title">
<ExternalLink size={16} />
<span>Источники</span>
</div>
<ul className="rp-sources">
{pkg.sources.map((src, i) => (
<li key={i}>
{src.url ? (
<a href={src.url} target="_blank" rel="noopener noreferrer">
{src.title}
</a>
) : (
src.title
)}
</li>
))}
</ul>
</section>
)}
{!isLowConfidence && pkg.colleagueCases && pkg.colleagueCases.length > 0 && (
<section className="rp-section">
<div className="rp-section-title">
<Users size={16} />
<span>Что помогало в похожих случаях</span>
</div>
<div className="rp-cases">
{pkg.colleagueCases.map((c, i) => (
<div key={i} className="rp-case">
<strong>{c.summary}</strong>
<span> {c.outcome}</span>
</div>
))}
</div>
</section>
)}
{!isLowConfidence && pkg.warning && (
<div className="rp-warning">
<AlertTriangle size={16} />
<span>{pkg.warning}</span>
</div>
)}
{showFeedbackBlock && (
<div className="rp-feedback">
<span className="rp-feedback-label">Помог ли этот ответ?</span>
<div className="rp-feedback-btns">
<button className="rp-feedback-yes" onClick={() => onFeedback(true)}>
<ThumbsUp size={16} />
Помогло
</button>
<button className="rp-feedback-no" onClick={() => onFeedback(false)}>
<ThumbsDown size={16} />
Не помогло
</button>
</div>
</div>
)}
{!isLowConfidence && draftBlock}
</div>
);
}
+186
View File
@@ -0,0 +1,186 @@
.modal-overlay {
position: fixed;
inset: 0;
background: rgba(15, 23, 42, 0.5);
display: flex;
align-items: center;
justify-content: center;
z-index: 100;
padding: 20px;
animation: fadeIn 0.2s ease-out;
}
.modal {
background: var(--surface);
border-radius: var(--radius);
box-shadow: 0 20px 60px rgba(15, 23, 42, 0.2);
width: 100%;
max-width: 520px;
max-height: 90vh;
overflow-y: auto;
}
.modal-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 18px 20px;
border-bottom: 1px solid var(--border);
}
.modal-header h3 {
margin: 0;
font-size: 1.1rem;
}
.modal-close {
border: none;
background: none;
color: var(--text-muted);
padding: 4px;
border-radius: 6px;
}
.modal-close:hover {
background: var(--surface-2);
color: var(--text);
}
.ticket-hint {
margin: 0;
padding: 12px 20px;
background: var(--primary-light);
color: var(--primary);
font-size: 0.85rem;
}
.ticket-error {
margin: 0;
padding: 10px 20px;
background: #fef2f2;
color: #b91c1c;
font-size: 0.85rem;
}
.ticket-form {
padding: 20px;
display: flex;
flex-direction: column;
gap: 14px;
}
.ticket-form label {
display: flex;
flex-direction: column;
gap: 6px;
font-size: 0.82rem;
font-weight: 500;
color: var(--text-muted);
}
.ticket-form input,
.ticket-form select,
.ticket-form textarea {
padding: 10px 12px;
border: 1px solid var(--border);
border-radius: var(--radius-sm);
font-size: 0.9rem;
color: var(--text);
}
.ticket-form input:focus,
.ticket-form select:focus,
.ticket-form textarea:focus {
outline: none;
border-color: var(--primary);
}
.ticket-form input.readonly {
background: var(--surface-2);
color: var(--text-muted);
}
.modal-footer {
display: flex;
justify-content: flex-end;
gap: 10px;
padding: 16px 20px;
border-top: 1px solid var(--border);
}
.btn-primary,
.btn-secondary {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 10px 18px;
border-radius: var(--radius-sm);
font-size: 0.88rem;
font-weight: 600;
border: none;
transition: background 0.15s;
}
.btn-primary {
background: var(--primary);
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: var(--primary-hover);
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-secondary {
background: var(--surface-2);
color: var(--text);
border: 1px solid var(--border);
}
.btn-secondary:hover {
background: var(--border);
}
.ticket-success {
padding: 40px 30px;
text-align: center;
}
.success-icon {
color: var(--primary);
margin-bottom: 12px;
}
.ticket-success h3 {
margin: 0 0 8px;
font-size: 1.2rem;
}
.ticket-number {
font-size: 1.1rem;
font-weight: 700;
color: var(--primary);
margin: 0 0 8px;
}
.ticket-sla {
color: var(--text-muted);
font-size: 0.88rem;
margin: 0 0 24px;
}
.ticket-success-actions {
display: flex;
flex-direction: column;
gap: 10px;
}
.ticket-success-actions .btn-primary,
.ticket-success-actions .btn-secondary {
justify-content: center;
width: 100%;
}
+125
View File
@@ -0,0 +1,125 @@
import { useState } from 'react';
import { X, CheckCircle, ArrowRight } from 'lucide-react';
import { api } from '../api/client';
import type { Appeal, TicketDraft } from '../types';
import './TicketModal.css';
interface TicketModalProps {
draft?: TicketDraft;
system?: string;
onClose: () => void;
onCreated: (appeal: Appeal) => void;
onGoToAppeals: () => void;
}
export function TicketModal({ draft, system, onClose, onCreated, onGoToAppeals }: TicketModalProps) {
const [title, setTitle] = useState(draft?.title ?? 'Обращение в ИТ-поддержку');
const [description, setDescription] = useState(draft?.description ?? '');
const [priority, setPriority] = useState(draft?.priority ?? 'Средний');
const [isSubmitting, setIsSubmitting] = useState(false);
const [created, setCreated] = useState<Appeal | null>(null);
const [error, setError] = useState<string | null>(null);
const handleSubmit = async () => {
setIsSubmitting(true);
setError(null);
try {
const appeal = await api.createAppeal({
title,
description,
system: draft?.system ?? system ?? 'Общее',
priority,
});
setCreated(appeal);
onCreated(appeal);
} catch {
setError('Не удалось создать обращение. Проверьте подключение к серверу.');
} finally {
setIsSubmitting(false);
}
};
if (created) {
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal ticket-modal" onClick={(e) => e.stopPropagation()}>
<div className="ticket-success">
<CheckCircle size={48} className="success-icon" />
<h3>Обращение создано</h3>
<p className="ticket-number">{created.number}</p>
<p className="ticket-sla">
Ориентировочный срок обработки:{' '}
{created.priority === 'critical' || created.priority === 'high' ? '4 часа' : '1 рабочий день'}
</p>
<div className="ticket-success-actions">
<button className="btn-primary" onClick={onGoToAppeals}>
Перейти к обращениям
<ArrowRight size={16} />
</button>
<button className="btn-secondary" onClick={onClose}>
Закрыть
</button>
</div>
</div>
</div>
</div>
);
}
return (
<div className="modal-overlay" onClick={onClose}>
<div className="modal ticket-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3>Создание обращения</h3>
<button className="modal-close" onClick={onClose} aria-label="Закрыть">
<X size={20} />
</button>
</div>
<p className="ticket-hint">
Поля предзаполнены на основе диалога. Проверьте и при необходимости скорректируйте.
</p>
{error && <p className="ticket-error">{error}</p>}
<div className="ticket-form">
<label>
Тема
<input value={title} onChange={(e) => setTitle(e.target.value)} />
</label>
<label>
Система
<input value={draft?.system ?? system ?? ''} readOnly className="readonly" />
</label>
<label>
Приоритет
<select value={priority} onChange={(e) => setPriority(e.target.value)}>
<option>Критический</option>
<option>Высокий</option>
<option>Средний</option>
<option>Низкий</option>
</select>
</label>
<label>
Описание
<textarea
value={description}
onChange={(e) => setDescription(e.target.value)}
rows={4}
/>
</label>
</div>
<div className="modal-footer">
<button className="btn-secondary" onClick={onClose}>
Отмена
</button>
<button className="btn-primary" onClick={handleSubmit} disabled={isSubmitting || !title.trim()}>
{isSubmitting ? 'Создание...' : 'Создать обращение'}
</button>
</div>
</div>
</div>
);
}
+323
View File
@@ -0,0 +1,323 @@
.chat-page {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
background: var(--bg);
}
.chat-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 10px 32px 0;
max-width: 900px;
width: 100%;
margin: 0 auto;
}
.chat-context-title {
margin: 0;
font-size: 0.95rem;
font-weight: 600;
color: var(--text);
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.chat-history-btn {
display: inline-flex;
align-items: center;
gap: 6px;
border: 1px solid var(--border);
background: #fff;
color: var(--text);
border-radius: 999px;
padding: 6px 12px;
font-size: 0.8rem;
cursor: pointer;
}
.chat-history-btn:hover {
border-color: var(--primary);
color: var(--primary);
}
.chat-body {
flex: 1;
overflow-y: auto;
display: flex;
flex-direction: column;
min-height: 0;
}
/* ——— Пустое состояние (как на макете) ——— */
.chat-empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
padding: 40px 24px 24px;
text-align: center;
}
.chat-empty-icon {
width: 80px;
height: 80px;
border-radius: 50%;
background: var(--primary);
color: #fff;
display: flex;
align-items: center;
justify-content: center;
margin-bottom: 28px;
}
.chat-empty-title {
margin: 0 0 12px;
font-size: 1.65rem;
font-weight: 700;
color: var(--text);
letter-spacing: -0.02em;
}
.chat-empty-subtitle {
margin: 0 0 36px;
max-width: 480px;
font-size: 1rem;
color: var(--text-muted);
line-height: 1.55;
}
.quick-actions {
display: flex;
flex-direction: column;
align-items: center;
gap: 12px;
width: 100%;
max-width: 720px;
}
.quick-actions-row {
display: flex;
flex-wrap: wrap;
justify-content: center;
gap: 12px;
}
.quick-actions-row--center {
margin-top: 0;
}
.quick-action-btn {
padding: 12px 24px;
border: 1.5px solid var(--primary);
border-radius: 28px;
background: transparent;
color: var(--primary);
font-size: 0.95rem;
font-weight: 500;
white-space: nowrap;
transition: background 0.15s, color 0.15s;
}
.quick-action-btn:hover:not(:disabled) {
background: var(--primary-light);
}
.quick-action-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ——— Сообщения ——— */
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 24px 32px;
max-width: 900px;
width: 100%;
margin: 0 auto;
display: flex;
flex-direction: column;
gap: 16px;
}
.chat-message {
display: flex;
gap: 10px;
max-width: 90%;
}
.chat-message.user {
align-self: flex-end;
flex-direction: row-reverse;
}
.chat-avatar {
width: 34px;
height: 34px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font-size: 0.85rem;
font-weight: 600;
}
.chat-message.assistant .chat-avatar {
background: var(--primary-light);
color: var(--primary);
}
.chat-message.user .chat-avatar {
background: #e8e8e8;
color: var(--text-muted);
}
.chat-bubble-wrap {
display: flex;
flex-direction: column;
gap: 8px;
min-width: 0;
}
.chat-message.user .chat-bubble-wrap {
align-items: flex-end;
}
.chat-bubble {
padding: 12px 16px;
border-radius: 16px;
line-height: 1.5;
}
.chat-bubble.assistant {
background: var(--surface);
border: 1px solid var(--border);
border-top-left-radius: 4px;
}
.chat-bubble.user {
background: var(--primary);
color: #fff;
border-top-right-radius: 4px;
}
.chat-bubble.typing {
display: flex;
gap: 4px;
padding: 14px 18px;
color: var(--text-muted);
}
.chat-text {
margin: 0;
font-size: 0.95rem;
white-space: pre-wrap;
}
.chat-time {
font-size: 0.72rem;
color: var(--text-muted);
padding: 0 4px;
}
/* ——— Нижняя панель ввода ——— */
.chat-footer-support {
max-width: 900px;
margin: 0 auto 10px;
display: flex;
justify-content: flex-start;
}
.chat-support-btn {
display: inline-flex;
align-items: center;
gap: 8px;
padding: 8px 14px;
border: 1px solid var(--border);
border-radius: 20px;
background: #fff;
color: var(--text);
font-size: 0.84rem;
font-weight: 500;
cursor: pointer;
transition: border-color 0.15s, color 0.15s, background 0.15s;
}
.chat-support-btn:hover:not(:disabled) {
border-color: var(--primary);
color: var(--primary);
background: var(--primary-light);
}
.chat-support-btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.chat-footer {
flex-shrink: 0;
padding: 16px 32px 20px;
background: var(--bg);
border-top: 1px solid transparent;
}
.chat-footer-hint {
margin: 8px 0 0;
max-width: 900px;
margin-left: auto;
margin-right: auto;
font-size: 0.8rem;
color: var(--text-muted);
text-align: left;
padding-left: 4px;
}
@media (max-width: 768px) {
.chat-empty-title {
font-size: 1.35rem;
}
.quick-action-btn {
font-size: 0.85rem;
padding: 10px 18px;
}
.chat-messages,
.chat-footer {
padding-left: 16px;
padding-right: 16px;
}
.chat-message {
max-width: 95%;
}
.chat-toolbar {
padding-left: 16px;
padding-right: 16px;
}
}
@media (max-width: 520px) {
.quick-actions-row {
flex-direction: column;
align-items: center;
}
.quick-action-btn {
width: 100%;
max-width: 280px;
}
}
+456
View File
@@ -0,0 +1,456 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { History, Bot, Headphones } from 'lucide-react';
import type { Appeal, ChatMessage, TicketDraft } from '../types';
import { CONFIDENCE_THRESHOLD } from '../types';
import { api } from '../api/client';
import { ChatHistoryModal } from './ChatHistoryModal';
import { ChatInputRow } from './ChatInputRow';
import { InlineTicketForm } from './InlineTicketForm';
import { ProactiveHome } from './ProactiveHome';
import { ResponsePackageCard } from './ResponsePackageCard';
import './VitrinaView.css';
interface VitrinaViewProps {
appeals: Appeal[];
onTicketCreated: (appeal: Appeal) => void;
onGoToAppeals: () => void;
}
const FALLBACK_PROMPTS = [
'Проект в ЛИМС не виден',
'VPN не работает',
'Восстановить файл',
'Ошибка в Meridium',
];
function createId() {
return `msg-${Date.now()}-${Math.random().toString(36).slice(2, 7)}`;
}
export function VitrinaView({ appeals, onTicketCreated, onGoToAppeals }: VitrinaViewProps) {
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [prompts, setPrompts] = useState<string[]>(FALLBACK_PROMPTS);
const [input, setInput] = useState('');
const [isTyping, setIsTyping] = useState(false);
const [feedbackGiven, setFeedbackGiven] = useState<Set<string>>(new Set());
const [revealedDrafts, setRevealedDrafts] = useState<Set<string>>(new Set());
const [dismissedDrafts, setDismissedDrafts] = useState<Set<string>>(new Set());
const [showFooterTicket, setShowFooterTicket] = useState(false);
const [showHistory, setShowHistory] = useState(false);
const [lastQuery, setLastQuery] = useState('');
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const isFirstRef = useRef(true);
const hasConversation = messages.some((m) => m.role === 'user');
const hasAssistantPackage = messages.some(
(m) => m.role === 'assistant' && m.package != null,
);
const lastAssistPackage = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.package?.ticketDraft) {
return { messageId: msg.id, pkg: msg.package };
}
}
return null;
}, [messages]);
const footerDraft = useMemo((): TicketDraft => {
if (lastAssistPackage?.pkg.ticketDraft) {
return lastAssistPackage.pkg.ticketDraft;
}
const dialogLines = messages
.slice(-6)
.map((m) => `${m.role === 'user' ? 'Сотрудник' : 'Ассистент'}: ${m.text}`)
.join('\n');
return {
title: 'Обращение в ИТ-поддержку',
description: [
lastQuery ? `Вопрос: ${lastQuery}` : '',
dialogLines ? `\nДиалог:\n${dialogLines}` : '',
]
.filter(Boolean)
.join('\n'),
system: lastAssistPackage?.pkg.system ?? 'Общее',
priority: 'Средний',
};
}, [lastAssistPackage, messages, lastQuery]);
const chatContextTitle = useMemo(() => {
for (let i = messages.length - 1; i >= 0; i--) {
const msg = messages[i];
if (msg.role === 'assistant' && msg.package?.system) {
return `Вопрос по системе ${msg.package.system}`;
}
}
const lastUser = [...messages].reverse().find((m) => m.role === 'user');
if (lastUser) {
const preview = lastUser.text.length > 56 ? `${lastUser.text.slice(0, 56)}` : lastUser.text;
return `Диалог: ${preview}`;
}
return null;
}, [messages]);
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
}, []);
useEffect(() => {
if (hasConversation) scrollToBottom();
}, [messages, isTyping, hasConversation, showFooterTicket, scrollToBottom]);
useEffect(() => {
api.getPrompts().then(setPrompts).catch(() => {});
}, []);
useEffect(() => {
const handler = (e: Event) => {
const text = (e as CustomEvent<string>).detail;
if (text) {
setInput(text);
inputRef.current?.focus();
}
};
window.addEventListener('vkus-prefill', handler);
return () => window.removeEventListener('vkus-prefill', handler);
}, []);
const handleAppealCreated = (appeal: Appeal) => {
onTicketCreated(appeal);
setShowFooterTicket(false);
setMessages((prev) => [
...prev,
{
id: createId(),
role: 'assistant',
text: `Обращение ${appeal.number} создано. Отслеживайте статус во вкладке «История моих обращений».`,
timestamp: new Date(),
isSimple: true,
},
]);
};
const sendMessage = async (text: string) => {
const trimmed = text.trim();
if (!trimmed || isTyping) return;
const userMsg: ChatMessage = {
id: createId(),
role: 'user',
text: trimmed,
timestamp: new Date(),
};
setMessages((prev) => [...prev, userMsg]);
setInput('');
setIsTyping(true);
setLastQuery(trimmed);
setShowFooterTicket(false);
const conversation = [...messages, userMsg].map((m) => ({
role: m.role,
text: m.text,
}));
try {
const result = await api.sendMessage(trimmed, isFirstRef.current, conversation);
isFirstRef.current = false;
setMessages((prev) => [
...prev,
{
id: createId(),
role: 'assistant',
text: result.text,
timestamp: new Date(),
package: result.package,
isSimple: result.isSimple,
},
]);
} catch {
setMessages((prev) => [
...prev,
{
id: createId(),
role: 'assistant',
text: 'Ошибка связи с сервером. Проверьте, что backend запущен.',
timestamp: new Date(),
isSimple: true,
},
]);
} finally {
setIsTyping(false);
}
};
const handleFeedback = async (
messageId: string,
helped: boolean,
pkg?: ChatMessage['package'],
) => {
setFeedbackGiven((prev) => new Set(prev).add(messageId));
await api
.submitFeedback({
messageId,
helped,
query: lastQuery,
system: pkg?.system,
confidence: pkg?.confidence ?? 0,
})
.catch(() => {});
if (helped) {
setMessages((prev) => [
...prev,
{
id: createId(),
role: 'assistant',
text: 'Отлично! Рад, что смог помочь. Ваш отзыв поможет улучшить сервис.',
timestamp: new Date(),
isSimple: true,
},
]);
} else {
setRevealedDrafts((prev) => new Set(prev).add(messageId));
setDismissedDrafts((prev) => {
const next = new Set(prev);
next.delete(messageId);
return next;
});
if (!pkg?.ticketDraft) {
setMessages((prev) => [
...prev,
{
id: createId(),
role: 'assistant',
text: 'Понимаю. Заполните форму обращения ниже — я подготовлю черновик с контекстом диалога.',
timestamp: new Date(),
isSimple: true,
},
]);
setShowFooterTicket(true);
}
}
};
const handleContactSupport = () => {
if (!lastAssistPackage) return;
setRevealedDrafts((prev) => new Set(prev).add(lastAssistPackage.messageId));
setDismissedDrafts((prev) => {
const next = new Set(prev);
next.delete(lastAssistPackage.messageId);
return next;
});
setShowFooterTicket(false);
scrollToBottom();
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && (e.ctrlKey || !e.shiftKey)) {
e.preventDefault();
sendMessage(input);
}
};
const shouldShowDraft = (messageId: string, pkg: NonNullable<ChatMessage['package']>) => {
if (dismissedDrafts.has(messageId)) return false;
const isLow = pkg.confidence < CONFIDENCE_THRESHOLD;
if (isLow) return true;
return Boolean(pkg.ticketDraft) && revealedDrafts.has(messageId);
};
const resolveTicketDraft = (
pkg: NonNullable<ChatMessage['package']>,
userQuery: string,
): TicketDraft => {
if (pkg.ticketDraft) return pkg.ticketDraft;
return {
title: pkg.system ? `Вопрос по системе ${pkg.system}` : 'Обращение в ИТ-поддержку',
description: [
userQuery ? `Исходный вопрос: ${userQuery}` : '',
pkg.recommendation ? `\nКонтекст ответа:\n${pkg.recommendation.slice(0, 800)}` : '',
]
.filter(Boolean)
.join('\n'),
system: pkg.system ?? 'Общее',
priority: 'Высокий',
};
};
const showFooterSupportBtn = useMemo(() => {
if (!hasAssistantPackage || showFooterTicket) return false;
if (
lastAssistPackage &&
shouldShowDraft(lastAssistPackage.messageId, lastAssistPackage.pkg)
) {
return false;
}
return true;
}, [
hasAssistantPackage,
showFooterTicket,
lastAssistPackage,
revealedDrafts,
dismissedDrafts,
]);
return (
<div className="chat-page">
{hasConversation && (
<div className="chat-toolbar">
{chatContextTitle && <h2 className="chat-context-title">{chatContextTitle}</h2>}
<button
type="button"
className="chat-history-btn"
onClick={() => setShowHistory(true)}
>
<History size={16} />
История диалога
</button>
</div>
)}
<div className="chat-body">
{!hasConversation ? (
<ProactiveHome
appeals={appeals}
prompts={prompts}
isTyping={isTyping}
input={input}
inputRef={inputRef}
onInputChange={setInput}
onSend={() => sendMessage(input)}
onKeyDown={handleKeyDown}
onSendPrompt={sendMessage}
onGoToAppeals={onGoToAppeals}
/>
) : (
<div className="chat-messages">
{messages.map((msg, msgIndex) => {
const queryForPackage =
[...messages.slice(0, msgIndex)]
.reverse()
.find((m) => m.role === 'user')?.text ?? lastQuery;
return (
<div key={msg.id} className={`chat-message ${msg.role} animate-in`}>
<div className="chat-avatar">
{msg.role === 'assistant' ? <Bot size={18} /> : 'Я'}
</div>
<div className="chat-bubble-wrap">
<div className={`chat-bubble ${msg.role}`}>
<p className="chat-text">{msg.text}</p>
</div>
{msg.package &&
(msg.package.recommendation ||
msg.package.ticketDraft ||
msg.package.confidence < CONFIDENCE_THRESHOLD) && (
<ResponsePackageCard
pkg={msg.package}
ticketDraft={
shouldShowDraft(msg.id, msg.package)
? resolveTicketDraft(msg.package, queryForPackage)
: undefined
}
showFeedback={!feedbackGiven.has(msg.id)}
revealDraft={shouldShowDraft(msg.id, msg.package)}
onFeedback={(helped) => handleFeedback(msg.id, helped, msg.package)}
onRevealTicket={() => {
setRevealedDrafts((prev) => new Set(prev).add(msg.id));
setDismissedDrafts((prev) => {
const next = new Set(prev);
next.delete(msg.id);
return next;
});
}}
onAppealCreated={handleAppealCreated}
onGoToAppeals={onGoToAppeals}
onCancelTicket={() =>
setDismissedDrafts((prev) => new Set(prev).add(msg.id))
}
/>
)}
<span className="chat-time">
{msg.timestamp.toLocaleTimeString('ru-RU', { hour: '2-digit', minute: '2-digit' })}
</span>
</div>
</div>
);
})}
{showFooterTicket && (
<div className="chat-message assistant animate-in">
<div className="chat-avatar">
<Bot size={18} />
</div>
<div className="chat-bubble-wrap">
<div className="chat-bubble assistant">
<p className="chat-text">Оформление обращения в поддержку</p>
</div>
<InlineTicketForm
draft={footerDraft}
system={lastAssistPackage?.pkg.system}
onCreated={handleAppealCreated}
onCancel={() => setShowFooterTicket(false)}
onGoToAppeals={onGoToAppeals}
/>
</div>
</div>
)}
{isTyping && (
<div className="chat-message assistant animate-in">
<div className="chat-avatar">
<Bot size={18} />
</div>
<div className="chat-bubble assistant typing">
<span className="typing-dot"></span>
<span className="typing-dot"></span>
<span className="typing-dot"></span>
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{hasConversation && (
<footer className="chat-footer">
{showFooterSupportBtn && (
<div className="chat-footer-support">
<button
type="button"
className="chat-support-btn"
onClick={handleContactSupport}
disabled={isTyping}
>
<Headphones size={16} />
Обратиться в поддержку
</button>
</div>
)}
<ChatInputRow
value={input}
onChange={setInput}
onSend={() => sendMessage(input)}
onKeyDown={handleKeyDown}
disabled={isTyping}
inputRef={inputRef}
/>
<p className="chat-footer-hint">Ctrl+Enter или нажмите </p>
</footer>
)}
{showHistory && (
<ChatHistoryModal messages={messages} onClose={() => setShowHistory(false)} />
)}
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
:root {
--bg: #f5f5f5;
--surface: #ffffff;
--surface-2: #fafafa;
--border: #e0e0e0;
--text: #333333;
--text-muted: #888888;
--primary: #00b4c4;
--primary-hover: #009aab;
--primary-light: #e0f7fa;
--accent: #00b4c4;
--accent-light: #e0f7fa;
--warning: #d97706;
--warning-bg: #fffbeb;
--danger: #dc2626;
--shadow: 0 1px 4px rgba(0, 0, 0, 0.06);
--radius: 12px;
--radius-sm: 8px;
--header-height: 64px;
font-family: 'Inter', 'Segoe UI', system-ui, sans-serif;
line-height: 1.5;
color: var(--text);
background: var(--bg);
}
+10
View File
@@ -0,0 +1,10 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+138
View File
@@ -0,0 +1,138 @@
export type TabId = 'vitrina' | 'appeals' | 'catalog';
export type AppealStatus =
| 'new'
| 'in_progress'
| 'waiting'
| 'resolved'
| 'closed';
export interface Appeal {
id: string;
number: string;
title: string;
system: string;
status: AppealStatus;
statusLabel: string;
nextStep: string;
priority: 'critical' | 'high' | 'medium' | 'low';
priorityLabel: string;
createdAt: string;
updatedAt: string;
assignee?: string;
description: string;
resolution?: string;
comments: AppealComment[];
}
export interface AppealComment {
id: string;
author: string;
text: string;
date: string;
isSupport: boolean;
}
export interface ServiceItem {
id: string;
title: string;
category: string;
system: string;
description: string;
sla: string;
}
export interface Source {
title: string;
url?: string;
}
export interface ColleagueCase {
summary: string;
outcome: string;
}
export interface TicketDraft {
title: string;
description: string;
system: string;
priority: string;
}
export interface CreateAppealPayload {
title: string;
description: string;
system: string;
priority: string;
}
export interface AssistantResult {
text: string;
package?: ResponsePackage;
isSimple?: boolean;
needsClarification?: boolean;
createTicket?: boolean;
}
export interface ResponsePackage {
recommendation: string;
steps: string[];
sources: Source[];
colleagueCases?: ColleagueCase[];
warning?: string;
whySuggested?: string;
ticketDraft?: TicketDraft;
confidence: number;
system?: string;
}
/** Порог эскалации в ITSM (client_journey.md §2.3) */
export const CONFIDENCE_THRESHOLD = 70;
export type MessageRole = 'user' | 'assistant';
export interface ChatMessage {
id: string;
role: MessageRole;
text: string;
timestamp: Date;
package?: ResponsePackage;
isSimple?: boolean;
}
export interface UserProfile {
firstName: string;
patronymic: string;
lastName: string;
position: string;
department: string;
}
export interface ConversationTurn {
role: MessageRole;
text: string;
}
export interface FeedbackPayload {
messageId: string;
helped: boolean;
query: string;
system?: string;
confidence: number;
}
export interface NotificationItem {
id: string;
title: string;
text: string;
createdAt: string;
read: boolean;
linkTab?: TabId;
}
export interface AnalyticsSummary {
totalFeedback: number;
helpedCount: number;
notHelpedCount: number;
deflectionRate: number;
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />