v2: сравнение похожих закупок — группы, исключение, выгрузка Excel
This commit is contained in:
parent
4550692c68
commit
59e4769093
527
index.html
527
index.html
@ -3,41 +3,47 @@
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>Закупки — Töre</title>
|
||||
<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>">
|
||||
<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); }
|
||||
.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); }
|
||||
.kpi-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); }
|
||||
.kpi-value { font-size: var(--kt-ai-text-3xl); font-weight: var(--kt-ai-weight-bold); color: var(--kt-ai-fg); }
|
||||
.kpi-label { font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted); margin-top: var(--kt-ai-space-1); }
|
||||
.kpi-hint { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-faint); margin-top: var(--kt-ai-space-2); }
|
||||
.filters-bar { display: flex; flex-wrap: wrap; gap: var(--kt-ai-space-3); margin-bottom: var(--kt-ai-space-5); align-items: center; }
|
||||
.table-wrap { overflow-x: auto; }
|
||||
.kt-ai-table { width: 100%; border-collapse: collapse; }
|
||||
.kt-ai-table th { text-align: left; padding: var(--kt-ai-space-3) var(--kt-ai-space-4); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); border-bottom: 1px solid var(--kt-ai-border); }
|
||||
.kt-ai-table td { padding: var(--kt-ai-space-4); border-bottom: 1px solid var(--kt-ai-border); font-size: var(--kt-ai-text-sm); }
|
||||
.kt-ai-table tr:hover { background: var(--kt-ai-bg-soft); }
|
||||
.num { text-align: right; font-variant-numeric: tabular-nums; }
|
||||
.status-chip { display: inline-flex; padding: 2px 8px; border-radius: var(--kt-ai-radius-full); font-size: var(--kt-ai-text-xs); font-weight: var(--kt-ai-weight-medium); }
|
||||
.status-new { background: var(--kt-ai-bg-soft); color: var(--kt-ai-fg-muted); }
|
||||
.status-prep { background: var(--kt-ai-bg-soft); color: var(--kt-ai-chart-orange); }
|
||||
.status-active { background: var(--kt-ai-bg-soft); color: var(--kt-ai-chart-green); }
|
||||
.status-done { background: var(--kt-ai-bg-soft); color: var(--kt-ai-fg-muted); }
|
||||
.block-reason { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); margin-left: var(--kt-ai-space-2); }
|
||||
.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); }
|
||||
.chart-wrap { height: 200px; margin-top: var(--kt-ai-space-6); }
|
||||
.chart-bars { display: flex; align-items: flex-end; gap: 8px; height: 160px; padding-top: var(--kt-ai-space-4); }
|
||||
.chart-bar { flex: 1; background: var(--kt-ai-chart-blue); border-radius: 2px 2px 0 0; min-width: 24px; position: relative; }
|
||||
.chart-bar[data-kt-tip]::after { content: attr(data-kt-tip); position: absolute; top: -24px; left: 50%; transform: translateX(-50%); font-size: var(--kt-ai-text-xs); background: var(--kt-ai-fg); color: var(--kt-ai-bg); padding: 2px 6px; border-radius: 4px; opacity: 0; pointer-events: none; transition: opacity 0.2s; }
|
||||
.chart-bar:hover[data-kt-tip]::after { opacity: 1; }
|
||||
.chart-labels { display: flex; justify-content: space-between; margin-top: var(--kt-ai-space-2); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
|
||||
</style>
|
||||
</head>
|
||||
<body class="kt-ai-app" data-kt-data-mode="demo">
|
||||
@ -46,100 +52,66 @@
|
||||
<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>
|
||||
<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);">
|
||||
|
||||
<!-- KPI панель -->
|
||||
<div class="kpi-strip">
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" id="kpi-total">0</div>
|
||||
<div class="kpi-label">Всего закупок</div>
|
||||
<div class="kpi-hint">Все активные процедуры</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" id="kpi-not-started">0</div>
|
||||
<div class="kpi-label">Ещё не начаты</div>
|
||||
<div class="kpi-hint">Требуют подготовки</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" id="kpi-active">0</div>
|
||||
<div class="kpi-label">В работе</div>
|
||||
<div class="kpi-hint">Приём заявок идёт</div>
|
||||
</div>
|
||||
<div class="kpi-card">
|
||||
<div class="kpi-value" id="kpi-sum">0 ₸</div>
|
||||
<div class="kpi-label">Общая сумма</div>
|
||||
<div class="kpi-hint">Плановый бюджет</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Фильтры -->
|
||||
<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="new">Ещё не начаты</option>
|
||||
<option value="prep">Подготовка</option>
|
||||
<option value="active">В работе</option>
|
||||
<option value="done">Завершены</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kt-ai-field">
|
||||
<select class="kt-ai-select" id="filter-category" onchange="applyFilters()">
|
||||
<option value="all">Все категории</option>
|
||||
<option value="it">ИТ и оборудование</option>
|
||||
<option value="services">Услуги</option>
|
||||
<option value="materials">Материалы</option>
|
||||
<option value="construction">Строительство</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="kt-ai-field">
|
||||
<select class="kt-ai-select" id="filter-period" onchange="applyFilters()">
|
||||
<option value="all">Любой период</option>
|
||||
<option value="month">Этот месяц</option>
|
||||
<option value="quarter">Квартал</option>
|
||||
<option value="review">На рассмотрении</option>
|
||||
<option value="decided">Решение принято</option>
|
||||
</select>
|
||||
</div>
|
||||
<span class="kt-ai-spacer"></span>
|
||||
<button class="kt-ai-btn" data-variant="ghost" onclick="resetFilters()">Сбросить</button>
|
||||
<span style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">
|
||||
Найдено групп: <strong id="groups-count">0</strong>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- Таблица закупок -->
|
||||
<div class="kt-ai-card">
|
||||
<div class="kt-ai-table-wrap">
|
||||
<table class="kt-ai-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>№</th>
|
||||
<th>Наименование</th>
|
||||
<th>Категория</th>
|
||||
<th>Сумма</th>
|
||||
<th>Статус</th>
|
||||
<th>Дата начала</th>
|
||||
<th>Действие</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="procurements-table"></tbody>
|
||||
</table>
|
||||
</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 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="kt-ai-card" style="margin-top: var(--kt-ai-space-5);">
|
||||
<h3 style="font-size: var(--kt-ai-text-lg); margin-bottom: var(--kt-ai-space-4);">Динамика закупок</h3>
|
||||
<div class="chart-wrap">
|
||||
<div class="chart-bars" id="chart-bars"></div>
|
||||
<div class="chart-labels" id="chart-labels"></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>
|
||||
|
||||
@ -148,181 +120,262 @@
|
||||
<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>
|
||||
<span style="font-size: var(--kt-ai-text-sm); color: var(--kt-ai-fg-muted);">Анализ и сравнение закупок</span>
|
||||
</footer>
|
||||
|
||||
<script>
|
||||
// Демо-данные с разными значениями под каждый фильтр
|
||||
const DEMO_DATA = [
|
||||
{ id: 'ZK-2026-001', name: 'Поставка серверного оборудования', category: 'it', sum: 45000000, status: 'new', date: '2026-01-15', startDate: '' },
|
||||
{ id: 'ZK-2026-002', name: 'Услуги техподдержки ПО', category: 'services', sum: 12000000, status: 'prep', date: '2026-01-10', startDate: '2026-02-01' },
|
||||
{ id: 'ZK-2026-003', name: 'Кабельная продукция', category: 'materials', sum: 8500000, status: 'active', date: '2025-12-20', startDate: '2026-01-05' },
|
||||
{ id: 'ZK-2026-004', name: 'Строительство ВОЛС', category: 'construction', sum: 78000000, status: 'new', date: '2026-01-08', startDate: '' },
|
||||
{ id: 'ZK-2026-005', name: 'Лицензии на ПО', category: 'it', sum: 5600000, status: 'active', date: '2025-12-15', startDate: '2025-12-28' },
|
||||
{ id: 'ZK-2026-006', name: 'Офисная мебель', category: 'materials', sum: 3200000, status: 'done', date: '2025-11-20', startDate: '2025-12-01' },
|
||||
{ id: 'ZK-2026-007', name: 'Монтаж систем безопасности', category: 'construction', sum: 15400000, status: 'prep', date: '2026-01-12', startDate: '2026-02-10' },
|
||||
{ id: 'ZK-2026-008', name: 'Обучение персонала', category: 'services', sum: 2800000, status: 'new', date: '2026-01-18', startDate: '' },
|
||||
{ id: 'ZK-2026-009', name: 'Сетевые коммутаторы', category: 'it', sum: 18900000, status: 'active', date: '2025-12-28', startDate: '2026-01-10' },
|
||||
{ id: 'ZK-2026-010', name: 'Уборка помещений', category: 'services', sum: 4100000, status: 'done', date: '2025-10-15', startDate: '2025-11-01' },
|
||||
{ id: 'ZK-2026-011', name: 'Оптические патч-корды', category: 'materials', sum: 1850000, status: 'prep', date: '2026-01-05', startDate: '2026-01-25' },
|
||||
{ id: 'ZK-2026-012', name: 'Проектирование ЦОД', category: 'construction', sum: 32000000, status: 'new', date: '2026-01-20', startDate: '' }
|
||||
// Демо-данные: группы похожих закупок для сравнения
|
||||
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 CATEGORY_NAMES = {
|
||||
'it': 'ИТ и оборудование',
|
||||
'services': 'Услуги',
|
||||
'materials': 'Материалы',
|
||||
'construction': 'Строительство'
|
||||
};
|
||||
|
||||
const STATUS_CONFIG = {
|
||||
'new': { label: 'Ещё не начата', class: 'status-new', canStart: false, reason: 'Срок не заполнен — укажите дату начала' },
|
||||
'prep': { label: 'Подготовка', class: 'status-prep', canStart: false, reason: 'Документы на согласовании' },
|
||||
'active': { label: 'В работе', class: 'status-active', canStart: true, reason: '' },
|
||||
'done': { label: 'Завершена', class: 'status-done', canStart: false, reason: 'Процедура завершена' }
|
||||
};
|
||||
|
||||
const FILTERS = [
|
||||
{ id: 'status', label: 'Статус', affects: ['table', 'kpi', 'chart'] },
|
||||
{ id: 'category', label: 'Категория', affects: ['table', 'kpi'] },
|
||||
{ id: 'period', label: 'Период', affects: ['table', 'kpi', 'chart'] }
|
||||
{ id: 'group', label: 'Группа', affects: ['groups'] },
|
||||
{ id: 'status', label: 'Статус', affects: ['groups'] }
|
||||
];
|
||||
|
||||
let currentData = [...DEMO_DATA];
|
||||
let currentGroups = [...DEMO_GROUPS];
|
||||
|
||||
function formatSum(sum) {
|
||||
return sum.toLocaleString('ru-KZ') + ' ₸';
|
||||
}
|
||||
|
||||
function getStatusChip(status) {
|
||||
const cfg = STATUS_CONFIG[status];
|
||||
return `<span class="status-chip ${cfg.class}">${cfg.label}</span>`;
|
||||
}
|
||||
|
||||
function renderTable(data) {
|
||||
const tbody = document.getElementById('procurements-table');
|
||||
function renderGroups(groups) {
|
||||
const container = document.getElementById('groups-container');
|
||||
const emptyState = document.getElementById('empty-state');
|
||||
const groupsCount = document.getElementById('groups-count');
|
||||
|
||||
if (data.length === 0) {
|
||||
tbody.innerHTML = '';
|
||||
if (groups.length === 0) {
|
||||
container.innerHTML = '';
|
||||
emptyState.style.display = 'block';
|
||||
groupsCount.textContent = '0';
|
||||
return;
|
||||
}
|
||||
|
||||
emptyState.style.display = 'none';
|
||||
tbody.innerHTML = data.map(item => {
|
||||
const cfg = STATUS_CONFIG[item.status];
|
||||
const btnDisabled = !cfg.canStart ? 'disabled' : '';
|
||||
const btnText = item.status === 'done' ? 'Архив' : (item.status === 'active' ? 'Управление' : 'Начать');
|
||||
const reasonHtml = !cfg.canStart ? `<span class="block-reason">${cfg.reason}</span>` : '';
|
||||
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('');
|
||||
|
||||
return `<tr>
|
||||
<td style="font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-xs);">${item.id}</td>
|
||||
<td>${item.name}</td>
|
||||
<td>${CATEGORY_NAMES[item.category]}</td>
|
||||
<td class="num">${formatSum(item.sum)}</td>
|
||||
<td>${getStatusChip(item.status)}</td>
|
||||
<td>${item.startDate || '—'}</td>
|
||||
<td>
|
||||
<button class="kt-ai-btn" data-size="sm" ${btnDisabled} onclick="handleAction('${item.id}')">${btnText}</button>
|
||||
${reasonHtml}
|
||||
</td>
|
||||
</tr>`;
|
||||
}).join('');
|
||||
}
|
||||
|
||||
function renderKPI(data) {
|
||||
const total = data.length;
|
||||
const notStarted = data.filter(i => i.status === 'new').length;
|
||||
const active = data.filter(i => i.status === 'active').length;
|
||||
const sum = data.reduce((acc, i) => acc + i.sum, 0);
|
||||
|
||||
document.getElementById('kpi-total').textContent = total;
|
||||
document.getElementById('kpi-not-started').textContent = notStarted;
|
||||
document.getElementById('kpi-active').textContent = active;
|
||||
document.getElementById('kpi-sum').textContent = formatSum(sum);
|
||||
}
|
||||
|
||||
function renderChart(data) {
|
||||
const months = ['Янв', 'Фев', 'Мар', 'Апр', 'Май', 'Июн'];
|
||||
const monthData = [3, 5, 2, 4, 1, 2];
|
||||
const max = Math.max(...monthData);
|
||||
|
||||
const barsContainer = document.getElementById('chart-bars');
|
||||
const labelsContainer = document.getElementById('chart-labels');
|
||||
|
||||
barsContainer.innerHTML = monthData.map((val, idx) => {
|
||||
const height = (val / max) * 140;
|
||||
return `<div class="chart-bar" style="height: ${height}px;" data-kt-tip="${val} закуп.${idx === 0 ? ' (демо)' : ''}"></div>`;
|
||||
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('');
|
||||
|
||||
labelsContainer.innerHTML = months.map(m => `<span>${m}</span>`).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;
|
||||
const categoryFilter = document.getElementById('filter-category').value;
|
||||
const periodFilter = document.getElementById('filter-period').value;
|
||||
|
||||
let filtered = [...DEMO_DATA];
|
||||
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(i => i.status === statusFilter);
|
||||
filtered = filtered.filter(g => g.status === statusFilter);
|
||||
}
|
||||
|
||||
if (categoryFilter !== 'all') {
|
||||
filtered = filtered.filter(i => i.category === categoryFilter);
|
||||
}
|
||||
currentGroups = filtered;
|
||||
renderGroups(filtered);
|
||||
}
|
||||
|
||||
function exportToExcel() {
|
||||
let csv = '\uFEFF';
|
||||
csv += 'Группа;ID;Наименование;Характеристики;Сумма;Статус;Выбрана;Исключена;Причина\n';
|
||||
|
||||
if (periodFilter !== 'all') {
|
||||
const now = new Date('2026-01-20');
|
||||
const monthAgo = new Date('2026-01-01');
|
||||
const quarterAgo = new Date('2025-11-01');
|
||||
|
||||
filtered = filtered.filter(i => {
|
||||
const d = new Date(i.date);
|
||||
if (periodFilter === 'month') return d >= monthAgo;
|
||||
if (periodFilter === 'quarter') return d >= quarterAgo;
|
||||
return true;
|
||||
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`;
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
currentData = filtered;
|
||||
renderTable(filtered);
|
||||
renderKPI(filtered);
|
||||
}
|
||||
|
||||
function resetFilters() {
|
||||
document.getElementById('filter-status').value = 'all';
|
||||
document.getElementById('filter-category').value = 'all';
|
||||
document.getElementById('filter-period').value = 'all';
|
||||
applyFilters();
|
||||
}
|
||||
|
||||
function handleAction(id) {
|
||||
const item = DEMO_DATA.find(i => i.id === id);
|
||||
if (!item) return;
|
||||
|
||||
if (item.status === 'new') {
|
||||
alert('Нельзя начать: ' + STATUS_CONFIG['new'].reason);
|
||||
return;
|
||||
}
|
||||
if (item.status === 'prep') {
|
||||
alert('Нельзя начать: ' + STATUS_CONFIG['prep'].reason);
|
||||
return;
|
||||
}
|
||||
if (item.status === 'done') {
|
||||
alert('Процедура уже завершена');
|
||||
return;
|
||||
}
|
||||
|
||||
alert('Открыто управление закупкой ' + item.id + ': ' + item.name);
|
||||
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);
|
||||
}
|
||||
|
||||
// Инициализация
|
||||
renderTable(DEMO_DATA);
|
||||
renderKPI(DEMO_DATA);
|
||||
renderChart(DEMO_DATA);
|
||||
renderGroups(DEMO_GROUPS);
|
||||
</script>
|
||||
|
||||
</body>
|
||||
|
||||
Loading…
Reference in New Issue
Block a user