383 lines
19 KiB
HTML
383 lines
19 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru" data-theme="light">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Сравнение закупок — Töre</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); }
|
||
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); }
|
||
.groups-strip { display: flex; flex-direction: column; gap: var(--kt-ai-space-5); margin-top: var(--kt-ai-space-6); }
|
||
.group-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); }
|
||
.group-head { display: flex; align-items: center; gap: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-4); padding-bottom: var(--kt-ai-space-3); border-bottom: 1px solid var(--kt-ai-border); }
|
||
.group-title { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); }
|
||
.group-meta { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); }
|
||
.comparison-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap: var(--kt-ai-space-4); }
|
||
.compare-card { background: var(--kt-ai-bg-soft); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); position: relative; }
|
||
.compare-card.duplicate { border-color: var(--kt-ai-chart-red); background: rgba(255, 235, 238, 0.5); }
|
||
.compare-card.selected { border-color: var(--kt-ai-chart-blue); background: rgba(230, 244, 250, 0.5); }
|
||
.compare-card.excluded { opacity: 0.5; }
|
||
.compare-header { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--kt-ai-space-3); }
|
||
.compare-id { font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
|
||
.compare-name { font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); margin-bottom: var(--kt-ai-space-2); }
|
||
.compare-specs { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); margin-bottom: var(--kt-ai-space-3); }
|
||
.spec-row { display: flex; justify-content: space-between; padding: 4px 0; border-bottom: 1px dashed var(--kt-ai-border); }
|
||
.spec-row:last-child { border-bottom: none; }
|
||
.spec-label { color: var(--kt-ai-fg-muted); }
|
||
.spec-value { font-weight: var(--kt-ai-weight-medium); text-align: right; }
|
||
.compare-sum { font-size: var(--kt-ai-text-lg); font-weight: var(--kt-ai-weight-semibold); color: var(--kt-ai-fg); margin-top: var(--kt-ai-space-2); }
|
||
.compare-actions { display: flex; gap: var(--kt-ai-space-2); margin-top: var(--kt-ai-space-3); flex-wrap: wrap; }
|
||
.exclude-badge { position: absolute; top: var(--kt-ai-space-2); right: var(--kt-ai-space-2); background: var(--kt-ai-chart-red); color: white; font-size: var(--kt-ai-text-xs); padding: 2px 8px; border-radius: var(--kt-ai-radius-full); }
|
||
.decision-box { margin-top: var(--kt-ai-space-4); padding: var(--kt-ai-space-4); background: var(--kt-ai-bg-soft); border-radius: var(--kt-ai-radius-xl); }
|
||
.decision-field { margin-bottom: var(--kt-ai-space-3); }
|
||
.decision-label { font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); margin-bottom: var(--kt-ai-space-2); }
|
||
.kt-ai-textarea { width: 100%; min-height: 80px; font-family: var(--kt-ai-font-sans); }
|
||
.summary-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); gap: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-6); }
|
||
.summary-card { background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-card-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); text-align: center; }
|
||
.summary-value { font-size: var(--kt-ai-text-2xl); font-weight: var(--kt-ai-weight-bold); }
|
||
.summary-label { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-1); }
|
||
.filters-bar { display: flex; flex-wrap: wrap; gap: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-4); align-items: center; }
|
||
.empty-state { text-align: center; padding: var(--kt-ai-space-10); color: var(--kt-ai-fg-muted); }
|
||
</style>
|
||
</head>
|
||
<body class="kt-ai-app" data-kt-data-mode="demo">
|
||
|
||
<header class="kt-ai-pagehead">
|
||
<div class="kt-ai-wrap kt-ai-pagehead-in">
|
||
<div class="app-header">
|
||
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">
|
||
<strong>Сравнение закупок</strong>
|
||
</div>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
|
||
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="exportToExcel()">
|
||
📥 Excel
|
||
</button>
|
||
</div>
|
||
</header>
|
||
|
||
<main class="kt-ai-wrap" style="padding-top: var(--kt-ai-space-6); padding-bottom: var(--kt-ai-space-8);">
|
||
|
||
<!-- Фильтры -->
|
||
<div class="filters-bar">
|
||
<div class="kt-ai-field">
|
||
<select class="kt-ai-select" id="filter-group" onchange="applyFilters()">
|
||
<option value="all">Все группы</option>
|
||
<option value="servers">Серверы</option>
|
||
<option value="cables">Кабельная продукция</option>
|
||
<option value="switches">Коммутаторы</option>
|
||
<option value="services">Услуги</option>
|
||
</select>
|
||
</div>
|
||
<div class="kt-ai-field">
|
||
<select class="kt-ai-select" id="filter-status" onchange="applyFilters()">
|
||
<option value="all">Все статусы</option>
|
||
<option value="review">На рассмотрении</option>
|
||
<option value="decided">Решение принято</option>
|
||
</select>
|
||
</div>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">
|
||
Найдено групп: <strong id="groups-count">0</strong>
|
||
</span>
|
||
</div>
|
||
|
||
<!-- Группы закупок для сравнения -->
|
||
<div id="groups-container" class="groups-strip"></div>
|
||
<div id="empty-state" class="empty-state" style="display: none;">
|
||
<p>Нет групп для отображения</p>
|
||
<p style="font-size: var(--kt-ai-text-xs); margin-top: var(--kt-ai-space-2);">Попробуйте изменить параметры фильтрации</p>
|
||
</div>
|
||
|
||
<!-- Итоговая сводка -->
|
||
<div class="summary-strip">
|
||
<div class="summary-card">
|
||
<div class="summary-value" id="summary-total">0</div>
|
||
<div class="summary-label">Всего строк</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-value" id="summary-duplicates">0</div>
|
||
<div class="summary-label">Дубликатов</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-value" id="summary-excluded">0</div>
|
||
<div class="summary-label">Исключено</div>
|
||
</div>
|
||
<div class="summary-card">
|
||
<div class="summary-value" id="summary-saved">0 ₸</div>
|
||
<div class="summary-label">Экономия</div>
|
||
</div>
|
||
</div>
|
||
|
||
</main>
|
||
|
||
<footer class="kt-ai-wrap kt-ai-pagefoot">
|
||
<span>© 2026 Казахтелеком</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">Анализ и сравнение закупок</span>
|
||
</footer>
|
||
|
||
<script>
|
||
// Демо-данные: группы похожих закупок для сравнения
|
||
const DEMO_GROUPS = [
|
||
{
|
||
id: 'grp-001',
|
||
name: 'Серверы',
|
||
status: 'review',
|
||
items: [
|
||
{ id: 'ZK-2026-001', name: 'Поставка сервера Dell PowerEdge R760', specs: { 'CPU': '2x Intel Xeon Gold 6430', 'RAM': '256 GB DDR5', 'Storage': '4x 1.92TB SSD' }, sum: 12500000, isDuplicate: false, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-015', name: 'Сервер Dell PowerEdge R760', specs: { 'CPU': '2x Intel Xeon Gold 6430', 'RAM': '256 GB', 'Storage': '4x 1.92TB' }, sum: 12800000, isDuplicate: true, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-023', name: 'Серверная платформа (базовая)', specs: { 'CPU': '2x Intel Xeon Silver', 'RAM': '128 GB', 'Storage': '2x 960GB' }, sum: 8200000, isDuplicate: false, isExcluded: true, excludeReason: 'Не подходит по характеристикам' }
|
||
],
|
||
decision: { selected: 'ZK-2026-001', comment: '' }
|
||
},
|
||
{
|
||
id: 'grp-002',
|
||
name: 'Кабельная продукция',
|
||
status: 'decided',
|
||
items: [
|
||
{ id: 'ZK-2026-003', name: 'Кабель оптический 48 волокон', specs: { 'Тип': 'Одномодовый', 'Длина': '10 км', 'Конструкция': 'Центральный модуль' }, sum: 4200000, isDuplicate: false, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-018', name: 'Оптический кабель 48F', specs: { 'Тип': 'Одномодовый', 'Длина': '10000 м', 'Конструкция': 'Модульная' }, sum: 4350000, isDuplicate: true, isExcluded: true, excludeReason: 'Дублирующая позиция, выбрана ZK-2026-003' }
|
||
],
|
||
decision: { selected: 'ZK-2026-003', comment: 'Выбрана более низкая цена при одинаковых характеристиках' }
|
||
},
|
||
{
|
||
id: 'grp-003',
|
||
name: 'Коммутаторы',
|
||
status: 'review',
|
||
items: [
|
||
{ id: 'ZK-2026-009', name: 'Коммутатор Huawei S5735-L24P4X', specs: { 'Порты': '24x 1GbE PoE+', 'Uplink': '4x 10G SFP+', 'L3': 'Базовая' }, sum: 3800000, isDuplicate: false, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-027', name: 'Сетевой коммутатор 24 порта', specs: { 'Порты': '24x 1GbE', 'Uplink': '4x 1G SFP', 'L3': 'Нет' }, sum: 2100000, isDuplicate: false, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-031', name: 'Huawei S5735-L24P4X-A', specs: { 'Порты': '24x PoE+', 'Uplink': '4x 10G', 'L3': 'Base' }, sum: 3950000, isDuplicate: true, isExcluded: false, excludeReason: '' }
|
||
],
|
||
decision: { selected: '', comment: '' }
|
||
},
|
||
{
|
||
id: 'grp-004',
|
||
name: 'Услуги техподдержки',
|
||
status: 'decided',
|
||
items: [
|
||
{ id: 'ZK-2026-002', name: 'Техническая поддержка ПО (1 год)', specs: { 'Срок': '12 месяцев', 'Режим': '24/7', 'СР': '4 часа' }, sum: 12000000, isDuplicate: false, isExcluded: false, excludeReason: '' },
|
||
{ id: 'ZK-2026-021', name: 'Поддержка программного обеспечения', specs: { 'Срок': '1 год', 'Режим': 'Рабочие дни', 'СР': '8 часов' }, sum: 6500000, isDuplicate: false, isExcluded: true, excludeReason: 'Не подходит режим поддержки (только рабочие дни)' }
|
||
],
|
||
decision: { selected: 'ZK-2026-002', comment: 'Требуется поддержка 24/7 по критичным системам' }
|
||
}
|
||
];
|
||
|
||
const FILTERS = [
|
||
{ id: 'group', label: 'Группа', affects: ['groups'] },
|
||
{ id: 'status', label: 'Статус', affects: ['groups'] }
|
||
];
|
||
|
||
let currentGroups = [...DEMO_GROUPS];
|
||
|
||
function formatSum(sum) {
|
||
return sum.toLocaleString('ru-KZ') + ' ₸';
|
||
}
|
||
|
||
function renderGroups(groups) {
|
||
const container = document.getElementById('groups-container');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const groupsCount = document.getElementById('groups-count');
|
||
|
||
if (groups.length === 0) {
|
||
container.innerHTML = '';
|
||
emptyState.style.display = 'block';
|
||
groupsCount.textContent = '0';
|
||
return;
|
||
}
|
||
|
||
emptyState.style.display = 'none';
|
||
groupsCount.textContent = groups.length;
|
||
|
||
container.innerHTML = groups.map(group => {
|
||
const itemsHtml = group.items.map((item, idx) => {
|
||
const isSelected = group.decision.selected === item.id;
|
||
const cardClass = [
|
||
'compare-card',
|
||
item.isDuplicate ? 'duplicate' : '',
|
||
isSelected ? 'selected' : '',
|
||
item.isExcluded ? 'excluded' : ''
|
||
].filter(Boolean).join(' ');
|
||
|
||
const specsHtml = Object.entries(item.specs).map(([label, value]) =>
|
||
`<div class="spec-row"><span class="spec-label">${label}</span><span class="spec-value">${value}</span></div>`
|
||
).join('');
|
||
|
||
const excludeBadge = item.isExcluded ? `<span class="exclude-badge">Исключена</span>` : '';
|
||
const dupBadge = item.isDuplicate && !item.isExcluded ? `<span class="exclude-badge" style="background:var(--kt-ai-chart-orange)">Дубль</span>` : '';
|
||
|
||
const actionButtons = !item.isExcluded ? `
|
||
<button class="kt-ai-btn" data-size="sm" ${isSelected ? 'data-variant="primary"' : ''} onclick="selectItem('${group.id}', '${item.id}')">
|
||
${isSelected ? '✓ Выбрана' : 'Выбрать'}
|
||
</button>
|
||
<button class="kt-ai-btn" data-size="sm" data-variant="ghost" onclick="excludeItem('${group.id}', '${item.id}')">
|
||
Исключить
|
||
</button>
|
||
` : `<span style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted)">Исключена: ${item.excludeReason}</span>`;
|
||
|
||
return `
|
||
<div class="${cardClass}">
|
||
${excludeBadge}${dupBadge}
|
||
<div class="compare-header">
|
||
<span class="compare-id">${item.id}</span>
|
||
</div>
|
||
<div class="compare-name">${item.name}</div>
|
||
<div class="compare-specs">${specsHtml}</div>
|
||
<div class="compare-sum">${formatSum(item.sum)}</div>
|
||
<div class="compare-actions">${actionButtons}</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
const hasDecision = group.decision.selected || group.items.some(i => i.isExcluded);
|
||
const statusChip = group.status === 'decided'
|
||
? '<span class="kt-ai-chip" data-tone="green">Решение принято</span>'
|
||
: '<span class="kt-ai-chip" data-tone="orange">На рассмотрении</span>';
|
||
|
||
const decisionComment = group.decision.comment
|
||
? `<p style="font-size:var(--kt-ai-text-sm);margin-top:var(--kt-ai-space-2);color:var(--kt-ai-fg-muted)">${group.decision.comment}</p>`
|
||
: '';
|
||
|
||
return `
|
||
<div class="group-card">
|
||
<div class="group-head">
|
||
<span class="group-title">${group.name}</span>
|
||
${statusChip}
|
||
<span class="kt-ai-spacer"></span>
|
||
<span class="group-meta">${group.items.length} строк</span>
|
||
</div>
|
||
<div class="comparison-grid">${itemsHtml}</div>
|
||
<div class="decision-box">
|
||
<div class="decision-label">Комментарий к решению:</div>
|
||
<textarea class="kt-ai-textarea" placeholder="Введите обоснование выбора..." onchange="updateComment('${group.id}', this.value)">${group.decision.comment}</textarea>
|
||
${decisionComment}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('');
|
||
|
||
updateSummary(groups);
|
||
}
|
||
|
||
function updateSummary(groups) {
|
||
let total = 0, duplicates = 0, excluded = 0, saved = 0;
|
||
|
||
groups.forEach(g => {
|
||
g.items.forEach(i => {
|
||
total++;
|
||
if (i.isDuplicate) duplicates++;
|
||
if (i.isExcluded) excluded++;
|
||
});
|
||
|
||
// Экономия: разница между самой дорогой и выбранной
|
||
const activeItems = g.items.filter(i => !i.isExcluded);
|
||
if (activeItems.length > 1) {
|
||
const maxSum = Math.max(...activeItems.map(i => i.sum));
|
||
const selected = g.items.find(i => i.id === g.decision.selected);
|
||
if (selected) {
|
||
saved += maxSum - selected.sum;
|
||
}
|
||
}
|
||
});
|
||
|
||
document.getElementById('summary-total').textContent = total;
|
||
document.getElementById('summary-duplicates').textContent = duplicates;
|
||
document.getElementById('summary-excluded').textContent = excluded;
|
||
document.getElementById('summary-saved').textContent = formatSum(saved);
|
||
}
|
||
|
||
function selectItem(groupId, itemId) {
|
||
const group = currentGroups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
if (group.decision.selected === itemId) {
|
||
group.decision.selected = '';
|
||
} else {
|
||
group.decision.selected = itemId;
|
||
group.status = 'decided';
|
||
}
|
||
|
||
renderGroups(currentGroups);
|
||
}
|
||
|
||
function excludeItem(groupId, itemId) {
|
||
const group = currentGroups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const item = group.items.find(i => i.id === itemId);
|
||
if (!item) return;
|
||
|
||
const reason = prompt('Причина исключения:', 'Дублирующая позиция');
|
||
if (reason === null) return;
|
||
|
||
item.isExcluded = true;
|
||
item.excludeReason = reason || 'Исключено без указания причины';
|
||
|
||
if (group.decision.selected === itemId) {
|
||
group.decision.selected = '';
|
||
group.status = 'review';
|
||
}
|
||
|
||
renderGroups(currentGroups);
|
||
}
|
||
|
||
function updateComment(groupId, comment) {
|
||
const group = currentGroups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
group.decision.comment = comment;
|
||
}
|
||
|
||
function applyFilters() {
|
||
const groupFilter = document.getElementById('filter-group').value;
|
||
const statusFilter = document.getElementById('filter-status').value;
|
||
|
||
let filtered = [...DEMO_GROUPS];
|
||
|
||
if (groupFilter !== 'all') {
|
||
filtered = filtered.filter(g => g.id === groupFilter || g.name.toLowerCase().includes(groupFilter));
|
||
}
|
||
|
||
if (statusFilter !== 'all') {
|
||
filtered = filtered.filter(g => g.status === statusFilter);
|
||
}
|
||
|
||
currentGroups = filtered;
|
||
renderGroups(filtered);
|
||
}
|
||
|
||
function exportToExcel() {
|
||
let csv = '\uFEFF';
|
||
csv += 'Группа;ID;Наименование;Характеристики;Сумма;Статус;Выбрана;Исключена;Причина\n';
|
||
|
||
currentGroups.forEach(g => {
|
||
g.items.forEach(i => {
|
||
const specs = Object.entries(i.specs).map(([k,v]) => `${k}: ${v}`).join('; ');
|
||
const isSelected = i.id === g.decision.selected ? 'Да' : 'Нет';
|
||
const isExcluded = i.isExcluded ? 'Да' : 'Нет';
|
||
csv += `${g.name};${i.id};"${i.name}";"${specs}";${i.sum};${g.status};${isSelected};${isExcluded};"${i.excludeReason}"\n`;
|
||
});
|
||
});
|
||
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
|
||
const url = URL.createObjectURL(blob);
|
||
const a = document.createElement('a');
|
||
a.href = url;
|
||
a.download = 'sravnenie_zakupok.csv';
|
||
a.click();
|
||
URL.revokeObjectURL(url);
|
||
}
|
||
|
||
// Инициализация
|
||
renderGroups(DEMO_GROUPS);
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|