Экран сравнения позиций годового плана закупок

This commit is contained in:
tore 2026-09-14 21:09:24 +00:00
parent aed9c4e6b9
commit 00ed162f6a
3 changed files with 416 additions and 212 deletions

View File

@ -1,6 +1,12 @@
# Паспорт прочитал Я выбрал экран таблицей
# Годовой план закупок — экран сравнения позиций
Паспорт прочитал. Я выбрал экран с таблицей, а не конкретную платформу, Alem можно оставить как вашу рекомендацию. Сохранение решений и комментариев нужно сразу, не в будущих версиях. И добавь, что реальные данные обрабатываем только внутри КТ.
Паспорт прочитал. Я выбрал экран с таблицей сравнения позиций годового плана закупок, а не конкретную платформу. Alem можно оставить как рекомендацию. Сохранение решений и комментариев нужно сразу, не в будущих версиях. Реальные данные обрабатываем только внутри КТ.
## Бизнес-процесс
Сравнение позиций годового плана закупок по группам (ИТ-оборудование, Сетевое оборудование, ПО, Услуги). Каждая позиция имеет характеристики, срок поставки, город, цену, поставщика и оценку совместимости.
Ответственный сравнивает позиции, проверяет совместимость, исключает несовместимые позиции и принимает решения с комментариями. Массовые действия: согласовать все совместимые, исключить несовместимые. Выгрузка отчёта в Excel.
## Объём работы

View File

@ -3,12 +3,12 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>Система принятия решений | Казахтелеком</title>
<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>">
<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>
.kt-ai-app { min-height: 100vh; background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-family: var(--kt-ai-font-sans); }
.kt-ai-header-content { display: flex; align-items: center; gap: var(--kt-ai-space-4); }
@ -29,10 +29,15 @@
.kt-ai-table tr:last-child td { border-bottom: none; }
.kt-ai-table tr:hover td { background: var(--kt-ai-bg-soft); }
.kt-ai-table .num { text-align: right; font-variant-numeric: tabular-nums; }
.kt-ai-table .group-row { background: var(--kt-ai-bg-soft); font-weight: var(--kt-ai-weight-semibold); cursor: pointer; }
.kt-ai-table .group-row:hover { background: var(--kt-ai-border); }
.kt-ai-table .group-icon { display: inline-block; width: 20px; text-align: center; margin-right: var(--kt-ai-space-2); transition: transform 0.2s; }
.kt-ai-table .group-row.collapsed .group-icon { transform: rotate(-90deg); }
.kt-ai-table .group-row.collapsed + tr { display: none; }
.kt-ai-empty { text-align: center; padding: var(--kt-ai-space-10); color: var(--kt-ai-fg-muted); }
.kt-ai-modal-overlay { position: fixed; inset: 0; background: rgba(0,0,0,0.5); display: none; align-items: center; justify-content: center; z-index: 1000; }
.kt-ai-modal-overlay.in { display: flex; }
.kt-ai-modal { background: var(--kt-ai-card-bg); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-6); max-width: 560px; width: 90%; max-height: 90vh; overflow-y: auto; }
.kt-ai-modal { background: var(--kt-ai-card-bg); border-radius: var(--kt-ai-radius-2xl); padding: var(--kt-ai-space-6); max-width: 640px; width: 90%; max-height: 90vh; overflow-y: auto; }
.kt-ai-modal-head { display: flex; justify-content: space-between; align-items: flex-start; margin-bottom: var(--kt-ai-space-5); }
.kt-ai-modal-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); }
.kt-ai-modal-close { background: none; border: none; font-size: var(--kt-ai-text-2xl); color: var(--kt-ai-fg-muted); cursor: pointer; padding: 0; line-height: 1; }
@ -42,13 +47,22 @@
.kt-ai-info-row:last-of-type { border-bottom: none; }
.kt-ai-info-label { color: var(--kt-ai-fg-muted); font-size: var(--kt-ai-text-sm); }
.kt-ai-info-value { font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-medium); }
.kt-ai-compare-block { background: var(--kt-ai-bg-soft); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); margin: var(--kt-ai-space-3) 0; }
.kt-ai-compare-title { font-size: var(--kt-ai-text-sm); font-weight: var(--kt-ai-weight-semibold); margin-bottom: var(--kt-ai-space-3); color: var(--kt-ai-fg-muted); }
.kt-ai-compare-grid { display: grid; grid-template-columns: repeat(2, 1fr); gap: var(--kt-ai-space-3); }
.kt-ai-compare-item { font-size: var(--kt-ai-text-xs); }
.kt-ai-compare-item .label { color: var(--kt-ai-fg-faint); margin-bottom: var(--kt-ai-space-1); }
.kt-ai-compare-item .value { font-weight: var(--kt-ai-weight-medium); }
.kt-ai-modal-actions { display: flex; gap: var(--kt-ai-space-3); margin-top: var(--kt-ai-space-5); padding-top: var(--kt-ai-space-5); border-top: 1px solid var(--kt-ai-border); }
.kt-ai-decision-note { font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-faint); margin-top: var(--kt-ai-space-2); }
.kt-ai-actions-bar { display: flex; justify-content: space-between; align-items: center; margin-top: var(--kt-ai-space-5); padding-top: var(--kt-ai-space-4); border-top: 1px solid var(--kt-ai-border); }
.kt-ai-bulk-actions { display: flex; gap: var(--kt-ai-space-3); }
@media (max-width: 768px) {
.kt-ai-kpi-strip { grid-template-columns: repeat(2, 1fr); }
.kt-ai-filters { flex-direction: column; align-items: stretch; }
.kt-ai-filter-group { width: 100%; }
.kt-ai-select { width: 100%; }
.kt-ai-compare-grid { grid-template-columns: 1fr; }
}
</style>
</head>
@ -58,7 +72,7 @@
<div class="kt-ai-wrap kt-ai-pagehead-in">
<div class="kt-ai-header-content">
<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>
@ -70,81 +84,86 @@
<section class="kt-ai-kpi-strip">
<div class="kt-ai-kpi">
<div class="value" id="kpi-total">0</div>
<div class="label">Всего заявок</div>
<div class="hint">за выбранный период</div>
<div class="label">Всего позиций</div>
<div class="hint">в плане закупок</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-review">0</div>
<div class="label">На рассмотрении</div>
<div class="label">На согласовании</div>
<div class="hint">требуют решения</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-approved" style="color: var(--kt-ai-status-ok-fg)">0</div>
<div class="label">Принято</div>
<div class="hint">за выбранный период</div>
<div class="label">Согласовано</div>
<div class="hint">включено в план</div>
</div>
<div class="kt-ai-kpi">
<div class="value" id="kpi-rejected" style="color: var(--kt-ai-status-risk-fg)">0</div>
<div class="label">Отклонено</div>
<div class="hint">за выбранный период</div>
<div class="value" id="kpi-excluded" style="color: var(--kt-ai-status-risk-fg)">0</div>
<div class="label">Исключено</div>
<div class="hint">несовместимые позиции</div>
</div>
</section>
<section class="kt-ai-filters">
<div class="kt-ai-filter-group">
<label class="kt-ai-filter-label">Период</label>
<select class="kt-ai-select" id="filter-period" style="min-width: 140px">
<option value="7d">7 дней</option>
<option value="30d">30 дней</option>
<option value="quarter">Квартал</option>
<option value="year">Год</option>
<label class="kt-ai-filter-label">Группа</label>
<select class="kt-ai-select" id="filter-group" style="min-width: 160px">
<option value="all">Все группы</option>
<option value="it">ИТ-оборудование</option>
<option value="network">Сетевое оборудование</option>
<option value="software">Программное обеспечение</option>
<option value="services">Услуги</option>
</select>
</div>
<div class="kt-ai-filter-group">
<label class="kt-ai-filter-label">Статус</label>
<select class="kt-ai-select" id="filter-status" style="min-width: 160px">
<option value="all">Все статусы</option>
<option value="new">Новый</option>
<option value="review">На рассмотрении</option>
<option value="decided">Решение принято</option>
<option value="review">На согласовании</option>
<option value="approved">Согласовано</option>
<option value="rejected">Отклонено</option>
<option value="excluded">Исключено</option>
</select>
</div>
<div class="kt-ai-filter-group">
<label class="kt-ai-filter-label">Тип заявки</label>
<select class="kt-ai-select" id="filter-type" style="min-width: 160px">
<option value="all">Все типы</option>
<option value="access">Доступ к данным</option>
<option value="integration">Интеграция</option>
<option value="hardware">Оборудование</option>
<option value="other">Другое</option>
<label class="kt-ai-filter-label">Город</label>
<select class="kt-ai-select" id="filter-city" style="min-width: 140px">
<option value="all">Все города</option>
<option value="almaty">Алматы</option>
<option value="astana">Астана</option>
<option value="shymkent">Шымкент</option>
<option value="other">Другие</option>
</select>
</div>
<div class="kt-ai-filter-group">
<label class="kt-ai-filter-label">Приоритет</label>
<select class="kt-ai-select" id="filter-priority" style="min-width: 140px">
<option value="all">Все приоритеты</option>
<option value="high">Высокий</option>
<option value="medium">Средний</option>
<option value="low">Низкий</option>
<label class="kt-ai-filter-label">Совместимость</label>
<select class="kt-ai-select" id="filter-compat" style="min-width: 160px">
<option value="all">Все</option>
<option value="compatible">Совместима</option>
<option value="check">Требует проверки</option>
<option value="incompatible">Несовместима</option>
</select>
</div>
<span class="kt-ai-spacer"></span>
<button class="kt-ai-btn" data-variant="ghost" id="btn-export" onclick="exportToExcel()">📄 Выгрузить Excel</button>
</section>
<section class="kt-ai-table-wrap">
<table class="kt-ai-table">
<thead>
<tr>
<th>Дата</th>
<th>Заявитель</th>
<th>Тип</th>
<th>Приоритет</th>
<th style="width: 40px"></th>
<th>Наименование</th>
<th>Характеристики</th>
<th>Срок</th>
<th>Город</th>
<th class="num">Цена (₸)</th>
<th>Совместимость</th>
<th>Статус</th>
<th>Решение</th>
<th></th>
</tr>
</thead>
<tbody id="requests-table-body">
<tbody id="positions-table-body">
</tbody>
</table>
<div id="empty-state" class="kt-ai-empty" style="display: none">
@ -153,24 +172,39 @@
</div>
</section>
<div class="kt-ai-actions-bar">
<div class="kt-ai-bulk-actions">
<button class="kt-ai-btn" data-variant="primary" id="btn-approve-all" onclick="approveAll()">✓ Согласовать все совместимые</button>
<button class="kt-ai-btn" data-variant="danger" id="btn-exclude-incompatible" onclick="excludeIncompatible()">✗ Исключить несовместимые</button>
</div>
<p class="kt-ai-decision-note" id="bulk-result"></p>
</div>
</main>
<div class="kt-ai-modal-overlay" id="modal-overlay">
<div class="kt-ai-modal">
<div class="kt-ai-modal-head">
<h2 class="kt-ai-modal-title">Рассмотрение заявки</h2>
<h2 class="kt-ai-modal-title">Согласование позиции</h2>
<button class="kt-ai-modal-close" id="modal-close">&times;</button>
</div>
<div class="kt-ai-modal-body">
<div id="request-details"></div>
<div id="position-details"></div>
<div class="kt-ai-compare-block">
<div class="kt-ai-compare-title">📊 Сравнение характеристик</div>
<div class="kt-ai-compare-grid" id="compare-grid"></div>
</div>
<div class="kt-ai-field">
<label for="decision-comment">Комментарий к решению</label>
<textarea id="decision-comment" class="kt-ai-textarea" rows="3" placeholder="Обоснование решения..."></textarea>
</div>
<div class="kt-ai-modal-actions">
<button class="kt-ai-btn" data-variant="primary" id="btn-approve">Принять</button>
<button class="kt-ai-btn" data-variant="primary" id="btn-approve">Согласовать</button>
<button class="kt-ai-btn" data-variant="danger" id="btn-reject">Отклонить</button>
<button class="kt-ai-btn" data-variant="ghost" id="btn-rework">На доработку</button>
<button class="kt-ai-btn" data-variant="ghost" id="btn-exclude">Исключить</button>
</div>
<p class="kt-ai-decision-note">Решение будет сохранено в localStorage</p>
</div>
@ -180,53 +214,50 @@
<script>
// Реестр фильтров
const FILTERS = [
{ id: 'period', label: 'Период', affects: ['table', 'kpi', 'empty'] },
{ id: 'group', label: 'Группа', affects: ['table', 'kpi', 'empty'] },
{ id: 'status', label: 'Статус', affects: ['table', 'kpi', 'empty'] },
{ id: 'type', label: 'Тип', affects: ['table', 'kpi', 'empty'] },
{ id: 'priority', label: 'Приоритет', affects: ['table', 'kpi', 'empty'] }
{ id: 'city', label: 'Город', affects: ['table', 'kpi', 'empty'] },
{ id: 'compat', label: 'Совместимость', affects: ['table', 'kpi', 'empty'] }
];
// Демо-данные
// Демо-данные по группам
const DEMO_DATA = {
'7d': [
{ id: 1, date: '2026-09-10', applicant: 'Иванов А.С.', department: 'Департамент ИТ', type: 'access', typeName: 'Доступ к данным', priority: 'high', priorityName: 'Высокий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 2, date: '2026-09-11', applicant: 'Петрова М.К.', department: 'Отдел аналитики', type: 'integration', typeName: 'Интеграция', priority: 'medium', priorityName: 'Средний', status: 'review', statusName: 'На рассмотрении', decision: null, comment: null },
{ id: 3, date: '2026-09-12', applicant: 'Сидоров Е.В.', department: 'Служба безопасности', type: 'hardware', typeName: 'Оборудование', priority: 'low', priorityName: 'Низкий', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Согласовано в рамках бюджета' },
{ id: 4, date: '2026-09-13', applicant: 'Алиев Н.Р.', department: 'Департамент ИТ', type: 'access', typeName: 'Доступ к данным', priority: 'high', priorityName: 'Высокий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 5, date: '2026-09-14', applicant: 'Козлова Д.А.', department: 'Бухгалтерия', type: 'other', typeName: 'Другое', priority: 'medium', priorityName: 'Средний', status: 'review', statusName: 'На рассмотрении', decision: null, comment: null }
'it': [
{ id: 1, group: 'it', groupName: 'ИТ-оборудование', name: 'Сервер Dell PowerEdge R750', specs: '2xCPU 16-core, 128GB RAM, 4TB SSD', deadline: '2026-12-15', city: 'almaty', cityName: 'Алматы', price: 4500000, qty: 2, supplier: 'Dell Technologies', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 2, group: 'it', groupName: 'ИТ-оборудование', name: 'Рабочая станция HP Z4 G5', specs: 'CPU i9-13900K, 64GB RAM, 2TB NVMe', deadline: '2026-11-30', city: 'astana', cityName: 'Астана', price: 1200000, qty: 5, supplier: 'HP Inc.', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 3, group: 'it', groupName: 'ИТ-оборудование', name: 'Ноутбук Lenovo ThinkPad P16', specs: 'CPU i7-13850HX, 32GB RAM, 1TB SSD', deadline: '2026-12-01', city: 'shymkent', cityName: 'Шымкент', price: 850000, qty: 10, supplier: 'Lenovo', compat: 'check', compatName: 'Требует проверки', status: 'review', statusName: 'На согласовании', decision: null, comment: null }
],
'30d': [
{ id: 6, date: '2026-09-01', applicant: 'Нурсултанов Б.Т.', department: 'Отдел маркетинга', type: 'integration', typeName: 'Интеграция', priority: 'medium', priorityName: 'Средний', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Интеграция одобрена' },
{ id: 7, date: '2026-09-03', applicant: 'Омарова Г.С.', department: 'Департамент ИТ', type: 'access', typeName: 'Доступ к данным', priority: 'high', priorityName: 'Высокий', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Доступ предоставлен' },
{ id: 8, date: '2026-09-05', applicant: 'Васильев П.И.', department: 'Служба поддержки', type: 'hardware', typeName: 'Оборудование', priority: 'low', priorityName: 'Низкий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 9, date: '2026-09-07', applicant: 'Жанарбеков А.К.', department: 'Отдел продаж', type: 'other', typeName: 'Другое', priority: 'medium', priorityName: 'Средний', status: 'review', statusName: 'На рассмотрении', decision: null, comment: null },
{ id: 10, date: '2026-09-09', applicant: 'Смирнова О.Л.', department: 'Департамент ИТ', type: 'access', typeName: 'Доступ к данным', priority: 'high', priorityName: 'Высокий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 11, date: '2026-09-10', applicant: 'Иванов А.С.', department: 'Департамент ИТ', type: 'access', typeName: 'Доступ к данным', priority: 'high', priorityName: 'Высокий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 12, date: '2026-09-11', applicant: 'Петрова М.К.', department: 'Отдел аналитики', type: 'integration', typeName: 'Интеграция', priority: 'medium', priorityName: 'Средний', status: 'review', statusName: 'На рассмотрении', decision: null, comment: null },
{ id: 13, date: '2026-09-12', applicant: 'Сидоров Е.В.', department: 'Служба безопасности', type: 'hardware', typeName: 'Оборудование', priority: 'low', priorityName: 'Низкий', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Согласовано в рамках бюджета' }
'network': [
{ id: 4, group: 'network', groupName: 'Сетевое оборудование', name: 'Маршрутизатор Cisco ISR 4351', specs: '3xGE WAN, 8xGE LAN, IOS XE', deadline: '2027-01-15', city: 'almaty', cityName: 'Алматы', price: 2800000, qty: 3, supplier: 'Cisco', compat: 'compatible', compatName: 'Совместима', status: 'approved', statusName: 'Согласовано', decision: 'approved', comment: 'Совместимость подтверждена' },
{ id: 5, group: 'network', groupName: 'Сетевое оборудование', name: 'Коммутатор Huawei S5735-L48', specs: '48xGE, 4x10G SFP+, PoE+', deadline: '2026-12-20', city: 'astana', cityName: 'Астана', price: 650000, qty: 8, supplier: 'Huawei', compat: 'incompatible', compatName: 'Несовместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 6, group: 'network', groupName: 'Сетевое оборудование', name: 'СКС-комплект 19"', specs: 'Шкаф 42U, патч-панели, кабель', deadline: '2026-11-25', city: 'other', cityName: 'Караганда', price: 380000, qty: 4, supplier: 'Legrand', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null }
],
'quarter': [
{ id: 14, date: '2026-07-15', applicant: 'Ахметов Р.Б.', department: 'Департамент ИТ', type: 'integration', typeName: 'Интеграция', priority: 'high', priorityName: 'Высокий', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Интеграция завершена' },
{ id: 15, date: '2026-08-01', applicant: 'Кузнецова А.В.', department: 'Отдел кадров', type: 'access', typeName: 'Доступ к данным', priority: 'medium', priorityName: 'Средний', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Доступ настроен' },
{ id: 16, date: '2026-08-10', applicant: 'Токтаев К.С.', department: 'Служба безопасности', type: 'hardware', typeName: 'Оборудование', priority: 'high', priorityName: 'Высокий', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Оборудование закуплено' },
{ id: 17, date: '2026-08-20', applicant: 'Лебедева М.А.', department: 'Бухгалтерия', type: 'other', typeName: 'Другое', priority: 'low', priorityName: 'Низкий', status: 'new', statusName: 'Новый', decision: null, comment: null },
{ id: 18, date: '2026-09-01', applicant: 'Нурсултанов Б.Т.', department: 'Отдел маркетинга', type: 'integration', typeName: 'Интеграция', priority: 'medium', priorityName: 'Средний', status: 'decided', statusName: 'Решение принято', decision: 'approved', decisionName: 'Принято', comment: 'Интеграция одобрена' },
{ id: 19, date: '2026-09-05', applicant: 'Васильев П.И.', department: 'Служба поддержки', type: 'hardware', typeName: 'Оборудование', priority: 'low', priorityName: 'Низкий', status: 'new', statusName: 'Новый', decision: null, comment: null }
'software': [
{ id: 7, group: 'software', groupName: 'Программное обеспечение', name: 'Лицензия Windows Server 2022', specs: 'Datacenter, 16 core, CAL', deadline: '2026-12-10', city: 'almaty', cityName: 'Алматы', price: 450000, qty: 5, supplier: 'Microsoft', compat: 'compatible', compatName: 'Совместима', status: 'approved', statusName: 'Согласовано', decision: 'approved', comment: 'Стандартная лицензия' },
{ id: 8, group: 'software', groupName: 'Программное обеспечение', name: 'Антивирус Kaspersky Endpoint', specs: '3 года, 500 устройств', deadline: '2027-01-05', city: 'astana', cityName: 'Астана', price: 1200000, qty: 1, supplier: 'Kaspersky', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 9, group: 'software', groupName: 'Программное обеспечение', name: '1С:Предприятие 8.3', specs: 'Сервер + 50 клиентских лицензий', deadline: '2026-12-05', city: 'shymkent', cityName: 'Шымкент', price: 980000, qty: 1, supplier: '1С', compat: 'check', compatName: 'Требует проверки', status: 'review', statusName: 'На согласовании', decision: null, comment: null }
],
'year': [] // Пустой срез для демонстрации пустого состояния
'services': [
{ id: 10, group: 'services', groupName: 'Услуги', name: 'Техническая поддержка 24/7', specs: '3 года, SLA 99.9%', deadline: '2027-02-01', city: 'almaty', cityName: 'Алматы', price: 5400000, qty: 1, supplier: 'КТ-Сервис', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 11, group: 'services', groupName: 'Услуги', name: 'Обучение персонала', specs: 'Курсы Cisco CCNA, 20 человек', deadline: '2027-03-15', city: 'astana', cityName: 'Астана', price: 2000000, qty: 1, supplier: 'УЦ "Сетевые Технологии"', compat: 'compatible', compatName: 'Совместима', status: 'review', statusName: 'На согласовании', decision: null, comment: null },
{ id: 12, group: 'services', groupName: 'Услуги', name: 'Аутсорсинг ИТ-инфраструктуры', specs: '2 года, полный спектр услуг', deadline: '2027-01-20', city: 'other', cityName: 'Актобе', price: 8600000, qty: 1, supplier: 'КТ-Аутсорсинг', compat: 'incompatible', compatName: 'Несовместима', status: 'excluded', statusName: 'Исключено', decision: 'excluded', comment: 'Дублирует внутренние ресурсы' }
]
};
// Пустой срез для демонстрации
DEMO_DATA['other'] = [];
// Загрузка сохранённых решений из localStorage
function loadSavedDecisions() {
const saved = localStorage.getItem('requestDecisions');
const saved = localStorage.getItem('procurementDecisions');
return saved ? JSON.parse(saved) : {};
}
// Сохранение решения в localStorage
function saveDecision(requestId, decision, comment) {
function saveDecision(positionId, decision, comment) {
const decisions = loadSavedDecisions();
decisions[requestId] = { decision, comment, date: new Date().toISOString() };
localStorage.setItem('requestDecisions', JSON.stringify(decisions));
decisions[positionId] = { decision, comment, date: new Date().toISOString() };
localStorage.setItem('procurementDecisions', JSON.stringify(decisions));
}
// Применение сохранённых решений к демо-данным
@ -235,12 +266,21 @@ function applySavedDecisions(data) {
return data.map(item => {
if (decisions[item.id]) {
const saved = decisions[item.id];
const statusMap = {
'approved': 'approved',
'rejected': 'rejected',
'excluded': 'excluded'
};
const statusNameMap = {
'approved': 'Согласовано',
'rejected': 'Отклонено',
'excluded': 'Исключено'
};
return {
...item,
decision: saved.decision,
decisionName: saved.decision === 'approved' ? 'Принято' : saved.decision === 'rejected' ? 'Отклонено' : 'На доработку',
status: 'decided',
statusName: 'Решение принято',
status: statusMap[saved.decision] || item.status,
statusName: statusNameMap[saved.decision] || item.statusName,
comment: saved.comment
};
}
@ -248,34 +288,41 @@ function applySavedDecisions(data) {
});
}
// Получение данных по фильтру периода
function getDataForPeriod(period) {
const baseData = DEMO_DATA[period] || [];
return applySavedDecisions(baseData);
// Получение всех данных
function getAllData() {
let allData = [];
for (const group in DEMO_DATA) {
allData = allData.concat(DEMO_DATA[group]);
}
return applySavedDecisions(allData);
}
// Фильтрация данных
function filterData(data, filters) {
let filtered = [...data];
if (filters.group !== 'all') {
filtered = filtered.filter(item => item.group === filters.group);
}
if (filters.status !== 'all') {
if (filters.status === 'new') {
filtered = filtered.filter(item => item.status === 'new');
} else if (filters.status === 'review') {
if (filters.status === 'review') {
filtered = filtered.filter(item => item.status === 'review');
} else if (filters.status === 'decided') {
filtered = filtered.filter(item => item.status === 'decided' && item.decision === 'approved');
} else if (filters.status === 'approved') {
filtered = filtered.filter(item => item.status === 'approved');
} else if (filters.status === 'rejected') {
filtered = filtered.filter(item => item.status === 'decided' && item.decision === 'rejected');
filtered = filtered.filter(item => item.status === 'rejected');
} else if (filters.status === 'excluded') {
filtered = filtered.filter(item => item.status === 'excluded');
}
}
if (filters.type !== 'all') {
filtered = filtered.filter(item => item.type === filters.type);
if (filters.city !== 'all') {
filtered = filtered.filter(item => item.city === filters.city);
}
if (filters.priority !== 'all') {
filtered = filtered.filter(item => item.priority === filters.priority);
if (filters.compat !== 'all') {
filtered = filtered.filter(item => item.compat === filters.compat);
}
return filtered;
@ -285,9 +332,9 @@ function filterData(data, filters) {
function calculateKPI(data) {
const total = data.length;
const review = data.filter(item => item.status === 'review').length;
const approved = data.filter(item => item.decision === 'approved').length;
const rejected = data.filter(item => item.decision === 'rejected').length;
return { total, review, approved, rejected };
const approved = data.filter(item => item.status === 'approved').length;
const excluded = data.filter(item => item.status === 'excluded').length;
return { total, review, approved, excluded };
}
// Отрисовка KPI
@ -295,36 +342,50 @@ function renderKPI(kpi) {
document.getElementById('kpi-total').textContent = kpi.total;
document.getElementById('kpi-review').textContent = kpi.review;
document.getElementById('kpi-approved').textContent = kpi.approved;
document.getElementById('kpi-rejected').textContent = kpi.rejected;
document.getElementById('kpi-excluded').textContent = kpi.excluded;
}
// Цвет статуса
function getStatusChip(status, decision) {
function getStatusChip(status) {
const chips = {
'new': '<span class="kt-ai-chip" data-tone="blue">Новый</span>',
'review': '<span class="kt-ai-chip" data-tone="orange">На рассмотрении</span>',
'decided': decision === 'approved'
? '<span class="kt-ai-chip" data-tone="green">Принято</span>'
: decision === 'rejected'
? '<span class="kt-ai-chip" data-tone="red">Отклонено</span>'
: '<span class="kt-ai-chip" data-tone="orange">На доработку</span>'
'review': '<span class="kt-ai-chip" data-tone="orange">На согласовании</span>',
'approved': '<span class="kt-ai-chip" data-tone="green">Согласовано</span>',
'rejected': '<span class="kt-ai-chip" data-tone="red">Отклонено</span>',
'excluded': '<span class="kt-ai-chip" data-tone="red">Исключено</span>'
};
return chips[status] || chips['new'];
return chips[status] || chips['review'];
}
// Цвет приоритета
function getPriorityChip(priority) {
// Цвет совместимости
function getCompatChip(compat) {
const chips = {
'high': '<span class="kt-ai-chip" data-tone="red">Высокий</span>',
'medium': '<span class="kt-ai-chip" data-tone="orange">Средний</span>',
'low': '<span class="kt-ai-chip" data-tone="blue">Низкий</span>'
'compatible': '<span class="kt-ai-chip" data-tone="green">Совместима</span>',
'check': '<span class="kt-ai-chip" data-tone="orange">Требует проверки</span>',
'incompatible': '<span class="kt-ai-chip" data-tone="red">Несовместима</span>'
};
return chips[priority] || chips['medium'];
return chips[compat] || chips['check'];
}
// Отрисовка таблицы
// Форматирование цены
function formatPrice(price) {
return price.toString().replace(/\B(?=(\d{3})+(?!\d))/g, ' ');
}
// Группировка данных по категориям
function groupByCategory(data) {
const groups = {};
data.forEach(item => {
if (!groups[item.group]) {
groups[item.group] = [];
}
groups[item.group].push(item);
});
return groups;
}
// Отрисовка таблицы с группировкой
function renderTable(data) {
const tbody = document.getElementById('requests-table-body');
const tbody = document.getElementById('positions-table-body');
const emptyState = document.getElementById('empty-state');
if (data.length === 0) {
@ -334,37 +395,71 @@ function renderTable(data) {
}
emptyState.style.display = 'none';
tbody.innerHTML = data.map(item => `
<tr>
<td>${item.date}</td>
<td>
<div style="font-weight: var(--kt-ai-weight-medium)">${item.applicant}</div>
<div style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted)">${item.department}</div>
</td>
<td>${item.typeName}</td>
<td>${getPriorityChip(item.priority)}</td>
<td>${getStatusChip(item.status, item.decision)}</td>
<td>${item.decision ? (item.decision === 'approved' ? '<span style="color: var(--kt-ai-status-ok-fg)">✓</span>' : '<span style="color: var(--kt-ai-status-risk-fg)">✗</span>') : '—'}</td>
<td class="num">
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="openModal(${item.id})">Рассмотреть</button>
</td>
</tr>
`).join('');
const grouped = groupByCategory(data);
let html = '';
for (const groupKey in grouped) {
const items = grouped[groupKey];
if (items.length === 0) continue;
const groupName = items[0].groupName;
html += `
<tr class="group-row" onclick="toggleGroup('${groupKey}')">
<td><span class="group-icon">▼</span></td>
<td colspan="8">${groupName} (${items.length} поз.)</td>
</tr>
`;
items.forEach(item => {
html += `
<tr class="group-item ${groupKey}">
<td></td>
<td>
<div style="font-weight: var(--kt-ai-weight-medium)">${item.name}</div>
<div style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted)">${item.supplier}</div>
</td>
<td style="font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted)">${item.specs}</td>
<td>${item.deadline}</td>
<td>${item.cityName}</td>
<td class="num">${formatPrice(item.price)} ₸</td>
<td>${getCompatChip(item.compat)}</td>
<td>${getStatusChip(item.status)}</td>
<td class="num">
<button class="kt-ai-btn" data-variant="ghost" data-size="sm" onclick="openModal(${item.id})">Согласовать</button>
</td>
</tr>
`;
});
}
tbody.innerHTML = html;
}
// Свёртка/развёртка группы
function toggleGroup(groupKey) {
const rows = document.querySelectorAll(`.group-item.${groupKey}`);
const groupRow = document.querySelector(`tr[onclick="toggleGroup('${groupKey}')"]`);
rows.forEach(row => {
row.style.display = row.style.display === 'none' ? '' : 'none';
});
groupRow.classList.toggle('collapsed');
}
// Текущие фильтры
let currentFilters = {
period: '7d',
group: 'all',
status: 'all',
type: 'all',
priority: 'all'
city: 'all',
compat: 'all'
};
let currentRequestId = null;
let currentPositionId = null;
// Обновление отображения
function updateDisplay() {
const data = getDataForPeriod(currentFilters.period);
const data = getAllData();
const filtered = filterData(data, currentFilters);
const kpi = calculateKPI(filtered);
@ -373,69 +468,155 @@ function updateDisplay() {
}
// Открытие модального окна
function openModal(requestId) {
const data = getDataForPeriod(currentFilters.period);
const request = data.find(item => item.id === requestId);
function openModal(positionId) {
const data = getAllData();
const position = data.find(item => item.id === positionId);
if (!request) return;
if (!position) return;
currentRequestId = requestId;
currentPositionId = positionId;
const details = document.getElementById('request-details');
const details = document.getElementById('position-details');
details.innerHTML = `
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Дата</span>
<span class="kt-ai-info-value">${request.date}</span>
<span class="kt-ai-info-label">Наименование</span>
<span class="kt-ai-info-value">${position.name}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Заявитель</span>
<span class="kt-ai-info-value">${request.applicant}</span>
<span class="kt-ai-info-label">Группа</span>
<span class="kt-ai-info-value">${position.groupName}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Отдел</span>
<span class="kt-ai-info-value">${request.department}</span>
<span class="kt-ai-info-label">Характеристики</span>
<span class="kt-ai-info-value">${position.specs}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Тип заявки</span>
<span class="kt-ai-info-value">${request.typeName}</span>
<span class="kt-ai-info-label">Срок поставки</span>
<span class="kt-ai-info-value">${position.deadline}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Приоритет</span>
<span class="kt-ai-info-value">${getPriorityChip(request.priority)}</span>
<span class="kt-ai-info-label">Город</span>
<span class="kt-ai-info-value">${position.cityName}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Цена</span>
<span class="kt-ai-info-value">${formatPrice(position.price)} ₸</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Количество</span>
<span class="kt-ai-info-value">${position.qty} шт.</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Поставщик</span>
<span class="kt-ai-info-value">${position.supplier}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Совместимость</span>
<span class="kt-ai-info-value">${getCompatChip(position.compat)}</span>
</div>
<div class="kt-ai-info-row">
<span class="kt-ai-info-label">Статус</span>
<span class="kt-ai-info-value">${getStatusChip(request.status, request.decision)}</span>
<span class="kt-ai-info-value">${getStatusChip(position.status)}</span>
</div>
`;
document.getElementById('decision-comment').value = request.comment || '';
// Сравнение характеристик
const compareGrid = document.getElementById('compare-grid');
const specs = position.specs.split(',').map(s => s.trim());
compareGrid.innerHTML = specs.map(spec => `
<div class="kt-ai-compare-item">
<div class="label">Параметр</div>
<div class="value">${spec}</div>
</div>
`).join('');
document.getElementById('decision-comment').value = position.comment || '';
document.getElementById('modal-overlay').classList.add('in');
}
// Закрытие модального окна
function closeModal() {
document.getElementById('modal-overlay').classList.remove('in');
currentRequestId = null;
currentPositionId = null;
}
// Сохранение решения
function makeDecision(decision) {
if (!currentRequestId) return;
if (!currentPositionId) return;
const comment = document.getElementById('decision-comment').value.trim();
saveDecision(currentRequestId, decision, comment);
saveDecision(currentPositionId, decision, comment);
closeModal();
updateDisplay();
}
// Массовое согласование совместимых
function approveAll() {
const data = getAllData();
const compatible = data.filter(item => item.compat === 'compatible' && item.status === 'review');
// Показать уведомление
alert('Решение сохранено!');
let count = 0;
compatible.forEach(item => {
saveDecision(item.id, 'approved', 'Автоматически согласовано как совместимое');
count++;
});
document.getElementById('bulk-result').textContent = `Согласовано ${count} позиций`;
updateDisplay();
}
// Исключение несовместимых
function excludeIncompatible() {
const data = getAllData();
const incompatible = data.filter(item => item.compat === 'incompatible' && item.status !== 'excluded');
let count = 0;
incompatible.forEach(item => {
saveDecision(item.id, 'excluded', 'Исключено как несовместимое');
count++;
});
document.getElementById('bulk-result').textContent = `Исключено ${count} несовместимых позиций`;
updateDisplay();
}
// Выгрузка в Excel (имитация)
function exportToExcel() {
const data = getAllData();
const filtered = filterData(data, currentFilters);
// Создаём простой CSV (Excel откроет)
const headers = ['ID', 'Группа', 'Наименование', 'Характеристики', 'Срок', 'Город', 'Цена', 'Кол-во', 'Поставщик', 'Совместимость', 'Статус', 'Решение', 'Комментарий'];
const rows = filtered.map(item => [
item.id,
item.groupName,
item.name,
item.specs,
item.deadline,
item.cityName,
item.price,
item.qty,
item.supplier,
item.compatName,
item.statusName,
item.decision || '',
item.comment || ''
]);
const csv = [headers, ...rows].map(row => row.join(';')).join('\n');
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = 'plan-zakupok.csv';
link.click();
URL.revokeObjectURL(url);
}
// Обработчики фильтров
document.getElementById('filter-period').addEventListener('change', (e) => {
currentFilters.period = e.target.value;
document.getElementById('filter-group').addEventListener('change', (e) => {
currentFilters.group = e.target.value;
updateDisplay();
});
@ -444,13 +625,13 @@ document.getElementById('filter-status').addEventListener('change', (e) => {
updateDisplay();
});
document.getElementById('filter-type').addEventListener('change', (e) => {
currentFilters.type = e.target.value;
document.getElementById('filter-city').addEventListener('change', (e) => {
currentFilters.city = e.target.value;
updateDisplay();
});
document.getElementById('filter-priority').addEventListener('change', (e) => {
currentFilters.priority = e.target.value;
document.getElementById('filter-compat').addEventListener('change', (e) => {
currentFilters.compat = e.target.value;
updateDisplay();
});
@ -462,13 +643,17 @@ document.getElementById('modal-overlay').addEventListener('click', (e) => {
document.getElementById('btn-approve').addEventListener('click', () => makeDecision('approved'));
document.getElementById('btn-reject').addEventListener('click', () => makeDecision('rejected'));
document.getElementById('btn-rework').addEventListener('click', () => makeDecision('rework'));
document.getElementById('btn-exclude').addEventListener('click', () => makeDecision('excluded'));
// Инициализация
updateDisplay();
// Сделать функцию доступной глобально
// Сделать функции доступными глобально
window.openModal = openModal;
window.toggleGroup = toggleGroup;
window.approveAll = approveAll;
window.excludeIncompatible = excludeIncompatible;
window.exportToExcel = exportToExcel;
</script>
</body>

View File

@ -1,8 +1,8 @@
# Паспорт бизнес-процесса: Система принятия решений по заявкам
# Паспорт бизнес-процесса: Согласование годового плана закупок
## Назначение системы
Система для обработки и принятия решений по входящим заявкам сотрудников. Позволяет ответственным лицам рассматривать заявки, принимать решения (принято/отклонено), добавлять комментарии и отслеживать статусы.
Система для согласования позиций годового плана закупок. Позволяет ответственным лицам сравнивать характеристики позиций, проверять совместимость, исключать неподходящие позиции и принимать решения с комментариями.
## Важные принципы
@ -12,70 +12,82 @@
## Сущности
### Заявка
- **ID** — уникальный номер
- **Дата создания** — когда поступила заявка
- **Заявитель** — ФИО сотрудника
- **Отдел/Департамент** — подразделение заявителя
- **Тип заявки** — доступ к данным / интеграция / оборудование / другое
- **Описание** — краткое описание потребности
- **Приоритет** — низкий / средний / высокий
- **Статус** — новый / на рассмотрении / решение принято
### Позиция плана закупок
- **ID** — уникальный номер позиции
- **Группа/Категория** — группа закупок (ИТ-оборудование, Сетевое оборудование, ПО, Услуги)
- **Наименование** — название позиции
- **Характеристики** — технические параметры (мощность, ёмкость, совместимость)
- **Срок поставки** — планируемая дата поставки
- **Город** — место доставки
- **Цена** — стоимость в тенге
- **Количество** — единиц
- **Поставщик** — рекомендуемый поставщик
- **Совместимость** — совместима / требует проверки / несовместима
- **Статус согласования** — на согласовании / согласовано / отклонено / исключено
### Решение
- **ID заявки** — ссылка на заявку
- **Решение** — принято / отклонено / на доработку
### Решение по позиции
- **ID позиции** — ссылка на позицию
- **Решение** — согласовано / отклонено / исключено / на доработку
- **Комментарий** — обоснование решения
- **Кто принял** — ФИО ответственного
- **Дата решения** — когда принято решение
## Статусы заявки
## Статусы позиции
| Статус | Описание | Что можно делать |
|--------|----------|------------------|
| Новый | Заявка только поступила | Назначить ответственного, взять в работу |
| На рассмотрении | Ответственный изучает заявку | Запросить доп. информацию, принять решение |
| Решение принято | Решение зафиксировано | Просмотр, экспорт в отчёт |
| Отклонено | Заявка отклонена | Просмотр, архивирование |
| На согласовании | Позиция ожидает решения | Сравнить, проверить совместимость, принять решение |
| Согласовано | Позиция одобрена | Просмотр, включение в итоговый план |
| Отклонено | Позиция отклонена | Просмотр, обоснование отклонения |
| Исключено | Позиция исключена из плана | Просмотр, причина исключения |
## Роли пользователей
## Группы позиций
| Роль | Права |
|------|-------|
| Заявитель | Создать заявку, просмотр своих заявок |
| Ответственный | Просмотр назначенных заявок, принятие решений, комментарии |
| Администратор | Все заявки, назначение ответственных, экспорт данных |
| Группа | Примеры |
|--------|---------|
| ИТ-оборудование | Серверы, рабочие станции, ноутбуки |
| Сетевое оборудование | Маршрутизаторы, коммутаторы, СКС |
| Программное обеспечение | Лицензии ПО, антивирусы, ОС |
| Услуги | Техническая поддержка, обучение, аутсорсинг |
## Интерфейс — экран с таблицей
## Интерфейс — экран сравнения позиций
### KPI-полоса (верхняя панель)
- Всего заявок
- На рассмотрении
- Принято за период
- Отклонено за период
- Всего позиций
- На согласовании
- Согласовано
- Исключено (несовместимые)
### Фильтры
- Период (7 дней / 30 дней / квартал / год)
- Статус (все / новый / на рассмотрении / решение принято / отклонено)
- Тип заявки (все / доступ / интеграция / оборудование / другое)
- Приоритет (все / высокий / средний / низкий)
- Группа (все / ИТ-оборудование / Сетевое / ПО / Услуги)
- Статус (все / на согласовании / согласовано / отклонено / исключено)
- Город (все / Алматы / Астана / Шымкент / другие)
- Совместимость (все / совместима / требует проверки / несовместима)
### Таблица заявок
### Таблица позиций (группированная)
Колонки:
1. Дата
2. Заявитель (с отделом)
3. Тип заявки
4. Приоритет (цветной чип)
5. Статус (цветной чип)
6. Решение (если принято)
7. Действия (кнопка «Рассмотреть»)
1. Группа (свёртка/развёртка)
2. Наименование
3. Характеристики
4. Срок поставки
5. Город
6. Цена (тенге)
7. Совместимость (цветной чип)
8. Статус (цветной чип)
9. Действия (кнопка «Согласовать», «Исключить»)
### Карточка рассмотрения (открывается по клику)
- Полная информация о заявке
### Карточка согласования (открывается по клику)
- Полная информация о позиции
- Сравнение с аналогами (если есть)
- Проверка совместимости
- Поле для комментария
- Кнопки: «Принять» / «Отклонить» / «На доработку»
- Кнопки: «Согласовать» / «Отклонить» / «Исключить»
- Сохранение в localStorage сразу после нажатия
### Выгрузка в Excel
- Кнопка «Выгрузить отчёт» — генерация Excel-файла с текущими данными
- Формат: XLSX, все поля позиции + решения + комментарии
## Хранение данных
### Демо-данные
@ -97,6 +109,7 @@
5. **Пустое состояние** — если нет данных под фильтр, показать причину
6. **Фильтры меняют всё** — таблица, KPI, текст объяснения
7. **Один пустой срез** — хотя бы один фильтр показывает «нет данных»
8. **Группировка строк** — свёртка/развёртка по категориям
## Запреты