Initial commit: VKUS vitrina MVP (frontend + backend).
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+46
@@ -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
@@ -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;
|
||||
@@ -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' }),
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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%;
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user