poprobuy-esche-raz-ekran-raz/index.html

494 lines
22 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Разбор похожих позиций — Годовой план закупок</title>
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
<link rel="stylesheet" href="design-system/kt-ai-components.css">
<link rel="stylesheet" href="design-system/kt-ai-page.css">
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
<style>
a.kt-ai-btn { text-decoration: none; }
.kt-ai-app { background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-family: var(--kt-ai-font-sans); min-height: 100vh; }
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) 0; }
.app-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); }
.filters-panel { display: flex; gap: var(--kt-ai-space-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--kt-ai-space-6); }
.kpi-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
.groups-list { display: flex; flex-direction: column; gap: var(--kt-ai-space-4); }
.group-card { border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); background: var(--kt-ai-card-bg); }
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
.group-items { display: flex; flex-direction: column; gap: var(--kt-ai-space-2); margin: var(--kt-ai-space-3) 0; }
.group-item { display: grid; grid-template-columns: auto 1fr auto auto auto; gap: var(--kt-ai-space-3); align-items: center; padding: var(--kt-ai-space-3); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); }
.group-item.selected { background: var(--kt-ai-bg-soft); border-color: var(--kt-ai-primary); }
.item-checkbox { width: 18px; height: 18px; cursor: pointer; }
.item-name { font-size: var(--kt-ai-text-sm); }
.item-code { font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
.item-amount { text-align: right; font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-sm); tabular-nums: tabular-nums; }
.item-category { font-size: var(--kt-ai-text-xs); }
.item-actions { display: flex; gap: var(--kt-ai-space-2); }
.group-actions { display: flex; gap: var(--kt-ai-space-3); align-items: center; margin-top: var(--kt-ai-space-4); padding-top: var(--kt-ai-space-4); border-top: 1px solid var(--kt-ai-border); }
.comment-field { flex: 1; }
.empty-state { text-align: center; padding: var(--kt-ai-space-12) var(--kt-ai-space-6); color: var(--kt-ai-fg-muted); }
.empty-icon { font-size: 48px; margin-bottom: var(--kt-ai-space-4); }
.status-chip { min-width: 100px; justify-self: end; }
.selection-panel { position: sticky; bottom: var(--kt-ai-space-4); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-6); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
.selection-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
</style>
</head>
<body class="kt-ai-app" data-kt-data-mode="demo">
<div class="kt-ai-wrap" style="padding-top:var(--kt-ai-space-6);padding-bottom:var(--kt-ai-space-8)">
<header class="app-header">
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">
<span class="app-title">Разбор похожих позиций</span>
<span class="kt-ai-spacer"></span>
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
</header>
<div class="kpi-strip" style="margin-top:var(--kt-ai-space-6)">
<div class="kt-ai-kpi">
<div class="value" id="kpi-total">0</div>
<div class="label">Всего групп</div>
<div class="hint">Найдено похожих</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-processed" style="color:var(--kt-ai-status-ok-fg)">0</div>
<div class="label">Обработано</div>
<div class="hint">Подтверждено или отклонено</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-remaining" style="color:var(--kt-ai-status-warn-fg)">0</div>
<div class="label">Осталось</div>
<div class="hint">Требует разбора</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-sum" style="color:var(--kt-ai-chart-blue)">0 ₸</div>
<div class="label">Сумма в работе</div>
<div class="hint">По выбранным группам</div>
</div>
</div>
<div class="filters-panel">
<div class="kt-ai-field" style="margin:0">
<label>Категория</label>
<select class="kt-ai-select" id="filter-category">
<option value="all">Все категории</option>
<option value="it">IT и оборудование</option>
<option value="services">Услуги</option>
<option value="materials">Материалы</option>
<option value="construction">Строительство</option>
</select>
</div>
<div class="kt-ai-field" style="margin:0">
<label>Статус</label>
<select class="kt-ai-select" id="filter-status">
<option value="all">Все статусы</option>
<option value="new">Новые</option>
<option value="review">На проверке</option>
<option value="confirmed">Подтверждено</option>
<option value="rejected">Отклонено</option>
</select>
</div>
<div class="kt-ai-field" style="margin:0">
<label>Период</label>
<select class="kt-ai-select" id="filter-period">
<option value="all">Весь период</option>
<option value="2026">2026 год</option>
<option value="2025">2025 год</option>
<option value="q1">Q1 2026</option>
<option value="q2">Q2 2026</option>
</select>
<span data-kt-filter-note style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted);margin-left:var(--kt-ai-space-2)">в демо не меняет набор</span>
</div>
<span class="kt-ai-spacer"></span>
<button class="kt-ai-btn" data-variant="ghost" id="btn-reset">Сбросить фильтры</button>
</div>
<div id="empty-state" class="empty-state" style="display:none">
<div class="empty-icon">✅</div>
<h3 style="margin-bottom:var(--kt-ai-space-2)">Все позиции разобраны</h3>
<p>Нет групп для обработки с выбранными фильтрами</p>
</div>
<div class="groups-list" id="groups-container"></div>
<div class="selection-panel" id="selection-panel" style="display:none">
<div class="selection-info">
<strong>Выбрано позиций: <span id="selected-count">0</span></strong>
<span class="kt-ai-chip" id="selection-status">Готово к обработке</span>
</div>
<div class="kt-ai-field comment-field">
<label>Комментарий к решению</label>
<textarea class="kt-ai-textarea" id="group-comment" rows="2" placeholder="Укажите причину подтверждения или отклонения..."></textarea>
</div>
<div style="display:flex;gap:var(--kt-ai-space-3);margin-top:var(--kt-ai-space-3)">
<button class="kt-ai-btn" data-variant="danger" id="btn-reject">Отклонить выбранные</button>
<span class="kt-ai-spacer"></span>
<button class="kt-ai-btn" data-variant="primary" id="btn-confirm">Подтвердить выбранные</button>
</div>
</div>
</div>
<script>
// Демо-данные: группы похожих позиций годового плана закупок
const DEMO_DATA = [
{
id: 'group-1',
status: 'new',
items: [
{ code: 'ZKP-2026-00142', name: 'Поставка компьютерного оборудования для нужд департамента', amount: 2450000, category: 'it', year: '2026', quarter: 'q1' },
{ code: 'ZKP-2026-00189', name: 'Закупка компьютерной техники для офисов', amount: 2380000, category: 'it', year: '2026', quarter: 'q1' },
{ code: 'ZKP-2026-00201', name: 'Поставка ПК и периферийного оборудования', amount: 2510000, category: 'it', year: '2026', quarter: 'q1' }
]
},
{
id: 'group-2',
status: 'new',
items: [
{ code: 'ZKP-2026-00312', name: 'Услуги по технической поддержке программного обеспечения', amount: 890000, category: 'services', year: '2026', quarter: 'q2' },
{ code: 'ZKP-2026-00345', name: 'Техническая поддержка и сопровождение ПО', amount: 920000, category: 'services', year: '2026', quarter: 'q2' }
]
},
{
id: 'group-3',
status: 'new',
items: [
{ code: 'ZKP-2026-00478', name: 'Приобретение кабельной продукции для магистральных линий', amount: 15600000, category: 'materials', year: '2026', quarter: 'q2' },
{ code: 'ZKP-2026-00489', name: 'Поставка кабеля и проводников для сетей связи', amount: 15200000, category: 'materials', year: '2026', quarter: 'q2' },
{ code: 'ZKP-2026-00501', name: 'Кабельно-проводниковая продукция для строительства ЛЭП', amount: 14900000, category: 'materials', year: '2026', quarter: 'q2' }
]
},
{
id: 'group-4',
status: 'new',
items: [
{ code: 'ZKP-2026-00623', name: 'Строительно-монтажные работы по реконструкции здания', amount: 42000000, category: 'construction', year: '2026', quarter: 'q1' },
{ code: 'ZKP-2026-00634', name: 'Реконструкция административного здания с капитальным ремонтом', amount: 43500000, category: 'construction', year: '2026', quarter: 'q1' }
]
},
{
id: 'group-5',
status: 'new',
items: [
{ code: 'ZKP-2025-01234', name: 'Услуги уборки служебных помещений', amount: 3200000, category: 'services', year: '2025', quarter: 'q4' },
{ code: 'ZKP-2025-01256', name: 'Клининговые услуги для офисных площадей', amount: 3100000, category: 'services', year: '2025', quarter: 'q4' }
]
},
{
id: 'group-6',
status: 'new',
items: [
{ code: 'ZKP-2026-00789', name: 'Поставка серверного оборудования и систем хранения', amount: 28700000, category: 'it', year: '2026', quarter: 'q3' },
{ code: 'ZKP-2026-00801', name: 'Закупка серверов и СХД для дата-центра', amount: 29100000, category: 'it', year: '2026', quarter: 'q3' }
]
}
];
const CATEGORY_NAMES = {
it: 'IT и оборудование',
services: 'Услуги',
materials: 'Материалы',
construction: 'Строительство'
};
const STATUS_LABELS = {
new: { text: 'Новые', tone: 'blue' },
review: { text: 'На проверке', tone: 'orange' },
confirmed: { text: 'Подтверждено', tone: 'green' },
rejected: { text: 'Отклонено', tone: 'red' }
};
// Состояние приложения
let appState = {
groups: [],
selectedItems: new Set(),
filters: { category: 'all', status: 'all', period: 'all' }
};
// Загрузка из localStorage
function loadState() {
const saved = localStorage.getItem('zakupki-review-state');
if (saved) {
try {
const parsed = JSON.parse(saved);
appState.groups = parsed.groups || [];
appState.filters = parsed.filters || { category: 'all', status: 'all', period: 'all' };
} catch (e) {
appState.groups = [];
}
}
if (appState.groups.length === 0) {
appState.groups = JSON.parse(JSON.stringify(DEMO_DATA));
}
}
// Сохранение в localStorage
function saveState() {
const toSave = {
groups: appState.groups,
filters: appState.filters
};
localStorage.setItem('zakupki-review-state', JSON.stringify(toSave));
}
// Форматирование суммы
function formatAmount(amount) {
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount);
}
// Рендер группы
function renderGroup(group, index) {
const statusInfo = STATUS_LABELS[group.status] || STATUS_LABELS.new;
const isProcessed = group.status === 'confirmed' || group.status === 'rejected';
let itemsHtml = '';
group.items.forEach((item, itemIndex) => {
const isSelected = appState.selectedItems.has(`${group.id}-${itemIndex}`);
itemsHtml += `
<div class="group-item ${isSelected ? 'selected' : ''}" data-group="${group.id}" data-item="${itemIndex}">
<input type="checkbox" class="item-checkbox" ${isSelected ? 'checked' : ''} ${isProcessed ? 'disabled' : ''}>
<div>
<div class="item-name">${item.name}</div>
<div class="item-code">${item.code}</div>
</div>
<div class="item-category"><span class="kt-ai-chip" data-tone="gray">${CATEGORY_NAMES[item.category]}</span></div>
<div class="item-amount">${formatAmount(item.amount)}</div>
<div class="status-chip"><span class="kt-ai-chip" data-tone="${statusInfo.tone}">${statusInfo.text}</span></div>
</div>
`;
});
return `
<div class="group-card" data-group-id="${group.id}" style="${isProcessed ? 'opacity:0.6' : ''}">
<div class="group-header">
<strong>Группа ${index + 1} &mdash; ${CATEGORY_NAMES[group.items[0].category]}</strong>
<span class="kt-ai-chip" data-tone="${statusInfo.tone}">${group.items.length} позиций</span>
</div>
<div class="group-items">${itemsHtml}</div>
${!isProcessed ? `
<div class="group-actions">
<span class="kt-ai-chip" data-tone="gray" style="font-size:var(--kt-ai-text-xs)">Похожесть: ${Math.floor(Math.random() * 15 + 85)}%</span>
<span class="kt-ai-spacer"></span>
<button class="kt-ai-btn kt-ai-btn-ghost btn-reject-group" data-group="${group.id}">Отклонить все</button>
<button class="kt-ai-btn kt-ai-btn-primary btn-confirm-group" data-group="${group.id}">Подтвердить все</button>
</div>
` : `<div style="margin-top:var(--kt-ai-space-3);font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Обработано: ${group.comment || 'без комментария'}</div>`}
</div>
`;
}
// Фильтрация групп
function filterGroups() {
const { category, status, period } = appState.filters;
return appState.groups.filter(group => {
if (group.status === 'confirmed' || group.status === 'rejected') {
if (status !== 'all' && status !== 'confirmed' && status !== 'rejected') return false;
if (status === 'confirmed' && group.status !== 'confirmed') return false;
if (status === 'rejected' && group.status !== 'rejected') return false;
} else {
if (status === 'confirmed' || status === 'rejected') return false;
if (status === 'review') return false;
}
const itemCategory = group.items[0].category;
if (category !== 'all' && itemCategory !== category) return false;
const itemPeriod = group.items[0].quarter;
const itemYear = group.items[0].year;
if (period !== 'all') {
if (period === '2026' && itemYear !== '2026') return false;
if (period === '2025' && itemYear !== '2025') return false;
if (period === 'q1' && itemPeriod !== 'q1') return false;
if (period === 'q2' && itemPeriod !== 'q2') return false;
}
return true;
});
}
// Обновление KPI
function updateKPI() {
const total = appState.groups.length;
const processed = appState.groups.filter(g => g.status === 'confirmed' || g.status === 'rejected').length;
const remaining = total - processed;
const remainingGroups = appState.groups.filter(g => g.status !== 'confirmed' && g.status !== 'rejected');
const sumInWork = remainingGroups.reduce((sum, g) => {
return sum + g.items.reduce((s, i) => s + i.amount, 0);
}, 0);
document.getElementById('kpi-total').textContent = total;
document.getElementById('kpi-processed').textContent = processed;
document.getElementById('kpi-remaining').textContent = remaining;
document.getElementById('kpi-sum').textContent = formatAmount(sumInWork);
}
// Рендер списка групп
function renderGroups() {
const container = document.getElementById('groups-container');
const emptyState = document.getElementById('empty-state');
const filtered = filterGroups();
if (filtered.length === 0) {
container.style.display = 'none';
emptyState.style.display = 'block';
document.getElementById('selection-panel').style.display = 'none';
return;
}
emptyState.style.display = 'none';
container.style.display = 'flex';
let html = '';
filtered.forEach((group, index) => {
html += renderGroup(group, index);
});
container.innerHTML = html;
updateKPI();
attachEventListeners();
}
// Прикрепление обработчиков
function attachEventListeners() {
document.querySelectorAll('.item-checkbox').forEach(cb => {
cb.addEventListener('change', function() {
const groupEl = this.closest('.group-item');
const groupId = groupEl.dataset.group;
const itemIndex = groupEl.dataset.item;
const key = `${groupId}-${itemIndex}`;
if (this.checked) {
appState.selectedItems.add(key);
groupEl.classList.add('selected');
} else {
appState.selectedItems.delete(key);
groupEl.classList.remove('selected');
}
updateSelectionPanel();
});
});
document.querySelectorAll('.btn-reject-group').forEach(btn => {
btn.addEventListener('click', function() {
const groupId = this.dataset.group;
processGroup(groupId, 'rejected', 'Отклонено массово');
});
});
document.querySelectorAll('.btn-confirm-group').forEach(btn => {
btn.addEventListener('click', function() {
const groupId = this.dataset.group;
processGroup(groupId, 'confirmed', 'Подтверждено массово');
});
});
}
// Обновление панели выбора
function updateSelectionPanel() {
const panel = document.getElementById('selection-panel');
const count = appState.selectedItems.size;
if (count === 0) {
panel.style.display = 'none';
return;
}
panel.style.display = 'block';
document.getElementById('selected-count').textContent = count;
}
// Обработка группы
function processGroup(groupId, status, defaultComment) {
const group = appState.groups.find(g => g.id === groupId);
if (!group) return;
const comment = document.getElementById('group-comment').value || defaultComment;
group.status = status;
group.comment = comment;
appState.selectedItems.clear();
saveState();
renderGroups();
document.getElementById('group-comment').value = '';
}
// Сброс фильтров
function resetFilters() {
appState.filters = { category: 'all', status: 'all', period: 'all' };
document.getElementById('filter-category').value = 'all';
document.getElementById('filter-status').value = 'all';
document.getElementById('filter-period').value = 'all';
saveState();
renderGroups();
}
// Инициализация
document.addEventListener('DOMContentLoaded', function() {
loadState();
renderGroups();
document.getElementById('filter-category').addEventListener('change', function() {
appState.filters.category = this.value;
saveState();
renderGroups();
});
document.getElementById('filter-status').addEventListener('change', function() {
appState.filters.status = this.value;
saveState();
renderGroups();
});
document.getElementById('filter-period').addEventListener('change', function() {
appState.filters.period = this.value;
saveState();
renderGroups();
});
document.getElementById('btn-reset').addEventListener('click', resetFilters);
document.getElementById('btn-confirm').addEventListener('click', function() {
if (appState.selectedItems.size === 0) return;
const comment = document.getElementById('group-comment').value || 'Подтверждено пользователем';
appState.selectedItems.forEach(key => {
const [groupId, itemIndex] = key.split('-');
const group = appState.groups.find(g => g.id === groupId);
if (group) {
group.status = 'confirmed';
group.comment = comment;
}
});
appState.selectedItems.clear();
saveState();
renderGroups();
document.getElementById('group-comment').value = '';
});
document.getElementById('btn-reject').addEventListener('click', function() {
if (appState.selectedItems.size === 0) return;
const comment = document.getElementById('group-comment').value || 'Отклонено пользователем';
appState.selectedItems.forEach(key => {
const [groupId, itemIndex] = key.split('-');
const group = appState.groups.find(g => g.id === groupId);
if (group) {
group.status = 'rejected';
group.comment = comment;
}
});
appState.selectedItems.clear();
saveState();
renderGroups();
document.getElementById('group-comment').value = '';
});
});
</script>
</body>
</html>