582 lines
30 KiB
HTML
582 lines
30 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>Разбор похожих позиций — Годовой план закупок</title>
|
||
<link rel="stylesheet" href="design-system/kt-ai-fonts.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-tokens.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-components.css">
|
||
<link rel="stylesheet" href="design-system/kt-ai-page.css">
|
||
<link rel="icon" href="data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 100 100'><text y='.9em' font-size='90'>📋</text></svg>">
|
||
<style>
|
||
a.kt-ai-btn { text-decoration: none; }
|
||
.kt-ai-app { background: var(--kt-ai-bg); color: var(--kt-ai-fg); font-family: var(--kt-ai-font-sans); min-height: 100vh; }
|
||
.app-header { display: flex; align-items: center; gap: var(--kt-ai-space-4); padding: var(--kt-ai-space-4) 0; }
|
||
.app-title { font-size: var(--kt-ai-text-xl); font-weight: var(--kt-ai-weight-semibold); }
|
||
.filters-panel { display: flex; gap: var(--kt-ai-space-3); flex-wrap: wrap; align-items: center; margin-bottom: var(--kt-ai-space-6); }
|
||
.kpi-strip { display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: var(--kt-ai-space-4); margin-bottom: var(--kt-ai-space-6); }
|
||
.groups-list { display: flex; flex-direction: column; gap: var(--kt-ai-space-4); }
|
||
.group-card { border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-5); background: var(--kt-ai-card-bg); }
|
||
.group-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
|
||
.group-items { display: flex; flex-direction: column; gap: var(--kt-ai-space-2); margin: var(--kt-ai-space-3) 0; }
|
||
.group-item { display: grid; grid-template-columns: auto 1.8fr auto auto auto; gap: var(--kt-ai-space-3); align-items: start; padding: var(--kt-ai-space-3); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-lg); }
|
||
.group-item.selected { background: var(--kt-ai-bg-soft); border-color: var(--kt-ai-primary); }
|
||
.item-checkbox { width: 18px; height: 18px; cursor: pointer; }
|
||
.item-name { font-size: var(--kt-ai-text-sm); }
|
||
.item-code { font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-xs); color: var(--kt-ai-fg-muted); }
|
||
.item-amount { text-align: right; font-family: var(--kt-ai-font-mono); font-size: var(--kt-ai-text-sm); tabular-nums: tabular-nums; }
|
||
.item-category { font-size: var(--kt-ai-text-xs); }
|
||
.item-actions { display: flex; gap: var(--kt-ai-space-2); }
|
||
.group-actions { display: flex; gap: var(--kt-ai-space-3); align-items: center; margin-top: var(--kt-ai-space-4); padding-top: var(--kt-ai-space-4); border-top: 1px solid var(--kt-ai-border); }
|
||
.comment-field { flex: 1; }
|
||
.empty-state { text-align: center; padding: var(--kt-ai-space-12) var(--kt-ai-space-6); color: var(--kt-ai-fg-muted); }
|
||
.empty-icon { font-size: 48px; margin-bottom: var(--kt-ai-space-4); }
|
||
.status-chip { min-width: 100px; justify-self: end; }
|
||
.selection-panel { position: sticky; bottom: var(--kt-ai-space-4); background: var(--kt-ai-card-bg); border: 1px solid var(--kt-ai-border); border-radius: var(--kt-ai-radius-xl); padding: var(--kt-ai-space-4); margin-top: var(--kt-ai-space-6); box-shadow: 0 4px 12px rgba(0,0,0,0.08); }
|
||
.selection-info { display: flex; justify-content: space-between; align-items: center; margin-bottom: var(--kt-ai-space-3); }
|
||
</style>
|
||
</head>
|
||
<body class="kt-ai-app" data-kt-data-mode="demo">
|
||
|
||
<div class="kt-ai-wrap" style="padding-top:var(--kt-ai-space-6);padding-bottom:var(--kt-ai-space-8)">
|
||
|
||
<header class="app-header">
|
||
<img src="https://code.vibe42.kz/tore/aitu/kt-logo.svg" alt="Казахтелеком" height="28">
|
||
<span class="app-title">Разбор похожих позиций</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<span class="kt-ai-chip" data-tone="orange">Демо-данные</span>
|
||
</header>
|
||
|
||
<div class="kpi-strip" style="margin-top:var(--kt-ai-space-6)">
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-total">0</div>
|
||
<div class="label">Всего групп</div>
|
||
<div class="hint">Найдено похожих</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-processed" style="color:var(--kt-ai-status-ok-fg)">0</div>
|
||
<div class="label">Обработано</div>
|
||
<div class="hint">Подтверждено или отклонено</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-remaining" style="color:var(--kt-ai-status-warn-fg)">0</div>
|
||
<div class="label">Осталось</div>
|
||
<div class="hint">Требует разбора</div>
|
||
</div>
|
||
<div class="kt-ai-kpi">
|
||
<div class="value" id="kpi-sum" style="color:var(--kt-ai-chart-blue)">0 ₸</div>
|
||
<div class="label">Сумма в работе</div>
|
||
<div class="hint">По выбранным группам</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div class="filters-panel">
|
||
<div class="kt-ai-field" style="margin:0">
|
||
<label>Категория</label>
|
||
<select class="kt-ai-select" id="filter-category">
|
||
<option value="all">Все категории</option>
|
||
<option value="it">IT и оборудование</option>
|
||
<option value="services">Услуги</option>
|
||
<option value="materials">Материалы</option>
|
||
<option value="construction">Строительство</option>
|
||
</select>
|
||
</div>
|
||
<div class="kt-ai-field" style="margin:0">
|
||
<label>Статус</label>
|
||
<select class="kt-ai-select" id="filter-status">
|
||
<option value="all">Все статусы</option>
|
||
<option value="new">Новые</option>
|
||
<option value="review">На проверке</option>
|
||
<option value="confirmed">Подтверждено</option>
|
||
<option value="rejected">Отклонено</option>
|
||
</select>
|
||
</div>
|
||
<div class="kt-ai-field" style="margin:0">
|
||
<label>Период</label>
|
||
<select class="kt-ai-select" id="filter-period">
|
||
<option value="all">Весь период</option>
|
||
<option value="2026">2026 год</option>
|
||
<option value="2025">2025 год</option>
|
||
<option value="q1">Q1 2026</option>
|
||
<option value="q2">Q2 2026</option>
|
||
<option value="q3">Q3 2026</option>
|
||
<option value="q4">Q4 2026</option>
|
||
</select>
|
||
<span data-kt-filter-note style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted);margin-left:var(--kt-ai-space-2)">в демо не меняет набор</span>
|
||
</div>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="btn-reset">Сбросить фильтры</button>
|
||
<button class="kt-ai-btn" data-variant="ghost" id="btn-clear-storage" title="Очистить сохранённые данные и загрузить демо заново">Сброс данных</button>
|
||
</div>
|
||
|
||
<div id="empty-state" class="empty-state" style="display:none">
|
||
<div class="empty-icon" id="empty-icon">✅</div>
|
||
<h3 style="margin-bottom:var(--kt-ai-space-2)" id="empty-title">Все позиции разобраны</h3>
|
||
<p id="empty-text">Нет групп для обработки с выбранными фильтрами</p>
|
||
</div>
|
||
|
||
<div class="groups-list" id="groups-container"></div>
|
||
|
||
<div class="selection-panel" id="selection-panel" style="display:none">
|
||
<div class="selection-info">
|
||
<strong>Выбрано позиций: <span id="selected-count">0</span></strong>
|
||
<span class="kt-ai-chip" id="selection-status">Готово к обработке</span>
|
||
</div>
|
||
<div class="kt-ai-field comment-field">
|
||
<label>Комментарий к решению</label>
|
||
<textarea class="kt-ai-textarea" id="group-comment" rows="2" placeholder="Укажите причину подтверждения или отклонения..."></textarea>
|
||
</div>
|
||
<div style="display:flex;gap:var(--kt-ai-space-3);margin-top:var(--kt-ai-space-3)">
|
||
<button class="kt-ai-btn" data-variant="danger" id="btn-reject">Отклонить выбранные</button>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn" data-variant="primary" id="btn-confirm">Подтвердить выбранные</button>
|
||
</div>
|
||
</div>
|
||
|
||
</div>
|
||
|
||
<script>
|
||
// Реестр фильтров по требованию брифа
|
||
const FILTERS = [
|
||
{ id: 'category', label: 'Категория', affects: ['table', 'kpi'] },
|
||
{ id: 'status', label: 'Статус', affects: ['table', 'kpi'] },
|
||
{ id: 'period', label: 'Период', affects: ['table'], demoOnly: true }
|
||
];
|
||
|
||
// Демо-данные: группы похожих позиций годового плана закупок
|
||
const DEMO_DATA = [
|
||
{
|
||
id: 'group-1',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00142', name: 'Поставка компьютерного оборудования для нужд департамента', amount: 2450000, category: 'it', year: '2026', quarter: 'q1', specs: 'ПК: Core i5, 16GB, 512GB SSD; Мониторы 24"', deliveryDate: '15.04.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, склад №3' },
|
||
{ code: 'ZKP-2026-00189', name: 'Закупка компьютерной техники для офисов', amount: 2380000, category: 'it', year: '2026', quarter: 'q1', specs: 'Ноутбуки: Core i5, 8GB, 256GB SSD; Мыши, клавиатуры', deliveryDate: '20.04.2026', deliveryPlace: 'г. Астана, ул. Сыганак 23, офис 401' },
|
||
{ code: 'ZKP-2026-00201', name: 'Поставка ПК и периферийного оборудования', amount: 2510000, category: 'it', year: '2026', quarter: 'q1', specs: 'Моноблоки 24", Core i3, 8GB, 1TB HDD; Принтеры МФУ', deliveryDate: '10.05.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, склад №3' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-2',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00312', name: 'Услуги по технической поддержке программного обеспечения', amount: 890000, category: 'services', year: '2026', quarter: 'q2', specs: 'ПО: 1С, Windows Server, антивирусы; 12 месяцев', deliveryDate: '01.06.2026', deliveryPlace: 'Удалённая поддержка + выезд в офисы' },
|
||
{ code: 'ZKP-2026-00345', name: 'Техническая поддержка и сопровождение ПО', amount: 920000, category: 'services', year: '2026', quarter: 'q2', specs: 'Сопровождение: 1С, CRM, документооборот; 12 месяцев', deliveryDate: '15.06.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, серверная' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-3',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00478', name: 'Приобретение кабельной продукции для магистральных линий', amount: 15600000, category: 'materials', year: '2026', quarter: 'q2', specs: 'Кабель оптический 48 волокон, 12 км; муфты, кроссы', deliveryDate: '10.07.2026', deliveryPlace: 'г. Шымкент, ул. Желтоксан 45, склад связи' },
|
||
{ code: 'ZKP-2026-00489', name: 'Поставка кабеля и проводников для сетей связи', amount: 15200000, category: 'materials', year: '2026', quarter: 'q2', specs: 'Кабель медный UTP cat.6, 25 км; коннекторы RJ45', deliveryDate: '25.07.2026', deliveryPlace: 'г. Тараз, пр. Толе би 89, РТС' },
|
||
{ code: 'ZKP-2026-00501', name: 'Кабельно-проводниковая продукция для строительства ЛЭП', amount: 14900000, category: 'materials', year: '2026', quarter: 'q2', specs: 'Провод СИП-3 70мм², 8 км; арматура, изоляторы', deliveryDate: '05.08.2026', deliveryPlace: 'г. Кызылорда, ул. Байтурсынова 12, склад №7' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-4',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00623', name: 'Строительно-монтажные работы по реконструкции здания', amount: 42000000, category: 'construction', year: '2026', quarter: 'q1', specs: 'Реконструкция 2 этажа, 850 м²; фасад, кровля, инженерия', deliveryDate: '30.09.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, здание А' },
|
||
{ code: 'ZKP-2026-00634', name: 'Реконструкция административного здания с капитальным ремонтом', amount: 43500000, category: 'construction', year: '2026', quarter: 'q1', specs: 'Капремонт 1-3 этажей, 1200 м²; замена коммуникаций', deliveryDate: '15.10.2026', deliveryPlace: 'г. Астана, ул. Сыганак 23, корпус 2' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-5',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2025-01234', name: 'Услуги уборки служебных помещений', amount: 3200000, category: 'services', year: '2025', quarter: 'q4', specs: 'Ежедневная уборка, 2500 м²; моющие средства включены', deliveryDate: '01.12.2025', deliveryPlace: 'г. Алматы, пр. Абая 125, все здания' },
|
||
{ code: 'ZKP-2025-01256', name: 'Клининговые услуги для офисных площадей', amount: 3100000, category: 'services', year: '2025', quarter: 'q4', specs: 'Комплексная уборка, 2200 м²; химчистка мебели', deliveryDate: '10.12.2025', deliveryPlace: 'г. Астана, ул. Сыганак 23, офисы 3-5 этажи' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-6',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00789', name: 'Поставка серверного оборудования и систем хранения', amount: 28700000, category: 'it', year: '2026', quarter: 'q3', specs: 'Серверы 2U: 2xXeon, 128GB, RAID; СХД 100ТБ', deliveryDate: '20.09.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, ЦОД' },
|
||
{ code: 'ZKP-2026-00801', name: 'Закупка серверов и СХД для дата-центра', amount: 29100000, category: 'it', year: '2026', quarter: 'q3', specs: 'Серверы 4U: 4xXeon, 256GB, NVMe; СХД 200ТБ FC', deliveryDate: '01.10.2026', deliveryPlace: 'г. Астана, пр. Мангилик Ел 55, ЦОД' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-7',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-00912', name: 'Поставка расходных материалов для оргтехники', amount: 1850000, category: 'materials', year: '2026', quarter: 'q4', specs: 'Картриджи: HP, Canon, Kyocera; бумага А4, 500 пачек', deliveryDate: '15.11.2026', deliveryPlace: 'г. Алматы, пр. Абая 125, склад канцтоваров' },
|
||
{ code: 'ZKP-2026-00934', name: 'Закупка тонеров и картриджей для принтеров', amount: 1920000, category: 'materials', year: '2026', quarter: 'q4', specs: 'Тонер-картриджи: Samsung, Xerox, Brother; 300 шт', deliveryDate: '25.11.2026', deliveryPlace: 'г. Шымкент, ул. Желтоксан 45, склад №2' }
|
||
]
|
||
},
|
||
{
|
||
id: 'group-8',
|
||
status: 'new',
|
||
items: [
|
||
{ code: 'ZKP-2026-01045', name: 'Услуги охраны объектов связи', amount: 12500000, category: 'services', year: '2026', quarter: 'q1', specs: 'Круглосуточная охрана, 15 объектов; пультовая охрана', deliveryDate: '01.01.2026', deliveryPlace: 'г. Алматы и Алматинская область, 15 РТС' },
|
||
{ code: 'ZKP-2026-01067', name: 'Охрана зданий и территорий компании', amount: 12800000, category: 'services', year: '2026', quarter: 'q1', specs: 'Физическая охрана, 18 постов; видеонаблюдение', deliveryDate: '15.01.2026', deliveryPlace: 'г. Астана и области, офисы и склады' }
|
||
]
|
||
}
|
||
];
|
||
|
||
const CATEGORY_NAMES = {
|
||
it: 'IT и оборудование',
|
||
services: 'Услуги',
|
||
materials: 'Материалы',
|
||
construction: 'Строительство'
|
||
};
|
||
|
||
const STATUS_LABELS = {
|
||
new: { text: 'Новые', tone: 'blue' },
|
||
review: { text: 'На проверке', tone: 'orange' },
|
||
confirmed: { text: 'Подтверждено', tone: 'green' },
|
||
rejected: { text: 'Отклонено', tone: 'red' }
|
||
};
|
||
|
||
// Состояние приложения
|
||
let appState = {
|
||
groups: [],
|
||
selectedItems: new Set(),
|
||
filters: { category: 'all', status: 'all', period: 'all' }
|
||
};
|
||
|
||
// Загрузка из localStorage
|
||
function loadState() {
|
||
const saved = localStorage.getItem('zakupki-review-state');
|
||
if (saved) {
|
||
try {
|
||
const parsed = JSON.parse(saved);
|
||
appState.groups = parsed.groups || [];
|
||
appState.filters = parsed.filters || { category: 'all', status: 'all', period: 'all' };
|
||
} catch (e) {
|
||
appState.groups = [];
|
||
}
|
||
}
|
||
if (appState.groups.length === 0) {
|
||
appState.groups = JSON.parse(JSON.stringify(DEMO_DATA));
|
||
}
|
||
}
|
||
|
||
// Сохранение в localStorage
|
||
function saveState() {
|
||
const toSave = {
|
||
groups: appState.groups,
|
||
filters: appState.filters
|
||
};
|
||
localStorage.setItem('zakupki-review-state', JSON.stringify(toSave));
|
||
}
|
||
|
||
// Форматирование суммы
|
||
function formatAmount(amount) {
|
||
return new Intl.NumberFormat('ru-RU', { style: 'currency', currency: 'KZT', maximumFractionDigits: 0 }).format(amount);
|
||
}
|
||
|
||
// Рендер группы
|
||
function renderGroup(group, index) {
|
||
const statusInfo = STATUS_LABELS[group.status] || STATUS_LABELS.new;
|
||
const isProcessed = group.status === 'confirmed' || group.status === 'rejected';
|
||
|
||
let itemsHtml = '';
|
||
group.items.forEach((item, itemIndex) => {
|
||
const isSelected = appState.selectedItems.has(`${group.id}-${itemIndex}`);
|
||
itemsHtml += `
|
||
<div class="group-item ${isSelected ? 'selected' : ''}" data-group="${group.id}" data-item="${itemIndex}">
|
||
<input type="checkbox" class="item-checkbox" ${isSelected ? 'checked' : ''} ${isProcessed ? 'disabled' : ''}>
|
||
<div style="min-width:0">
|
||
<div class="item-name" style="margin-bottom:var(--kt-ai-space-1)">${item.name}</div>
|
||
<div class="item-code">${item.code}</div>
|
||
<div style="font-size:var(--kt-ai-text-xs);color:var(--kt-ai-fg-muted);margin-top:var(--kt-ai-space-1)">
|
||
${item.specs ? `<div style="margin-bottom:2px"><strong>Характеристики:</strong> ${item.specs}</div>` : ''}
|
||
${item.deliveryDate ? `<div style="margin-bottom:2px"><strong>Срок поставки:</strong> ${item.deliveryDate}</div>` : '<div style="margin-bottom:2px"><strong>Срок поставки:</strong> <em>не указан</em></div>'}
|
||
${item.deliveryPlace ? `<div><strong>Место:</strong> ${item.deliveryPlace}</div>` : '<div><strong>Место:</strong> <em>не указано</em></div>'}
|
||
</div>
|
||
</div>
|
||
<div class="item-category"><span class="kt-ai-chip" data-tone="gray">${CATEGORY_NAMES[item.category]}</span></div>
|
||
<div class="item-amount">${formatAmount(item.amount)}</div>
|
||
<div class="status-chip"><span class="kt-ai-chip" data-tone="${statusInfo.tone}">${statusInfo.text}</span></div>
|
||
</div>
|
||
`;
|
||
});
|
||
|
||
return `
|
||
<div class="group-card" data-group-id="${group.id}" style="${isProcessed ? 'opacity:0.6' : ''}">
|
||
<div class="group-header">
|
||
<strong>Группа ${index + 1} — ${CATEGORY_NAMES[group.items[0].category]}</strong>
|
||
<span class="kt-ai-chip" data-tone="${statusInfo.tone}">${group.items.length} позиций</span>
|
||
</div>
|
||
<div class="group-items">${itemsHtml}</div>
|
||
${!isProcessed ? `
|
||
<div class="group-actions">
|
||
<span class="kt-ai-chip" data-tone="gray" style="font-size:var(--kt-ai-text-xs)">Похожесть: ${Math.floor(Math.random() * 15 + 85)}%</span>
|
||
<span class="kt-ai-spacer"></span>
|
||
<button class="kt-ai-btn kt-ai-btn-ghost btn-reject-group" data-group="${group.id}">Отклонить все</button>
|
||
<button class="kt-ai-btn kt-ai-btn-primary btn-confirm-group" data-group="${group.id}">Подтвердить все</button>
|
||
</div>
|
||
` : `<div style="margin-top:var(--kt-ai-space-3);font-size:var(--kt-ai-text-sm);color:var(--kt-ai-fg-muted)">Обработано: ${group.comment || 'без комментария'}</div>`}
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
// Фильтрация групп
|
||
function filterGroups() {
|
||
const { category, status, period } = appState.filters;
|
||
|
||
return appState.groups.filter(group => {
|
||
if (group.status === 'confirmed' || group.status === 'rejected') {
|
||
if (status !== 'all' && status !== 'confirmed' && status !== 'rejected') return false;
|
||
if (status === 'confirmed' && group.status !== 'confirmed') return false;
|
||
if (status === 'rejected' && group.status !== 'rejected') return false;
|
||
} else {
|
||
if (status === 'confirmed' || status === 'rejected') return false;
|
||
if (status === 'review') return false;
|
||
}
|
||
|
||
const itemCategory = group.items[0].category;
|
||
if (category !== 'all' && itemCategory !== category) return false;
|
||
|
||
const itemPeriod = group.items[0].quarter;
|
||
const itemYear = group.items[0].year;
|
||
if (period !== 'all') {
|
||
if (period === '2026' && itemYear !== '2026') return false;
|
||
if (period === '2025' && itemYear !== '2025') return false;
|
||
if (period === 'q1' && itemPeriod !== 'q1') return false;
|
||
if (period === 'q2' && itemPeriod !== 'q2') return false;
|
||
if (period === 'q3' && itemPeriod !== 'q3') return false;
|
||
if (period === 'q4' && itemPeriod !== 'q4') return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
// Обновление KPI
|
||
function updateKPI() {
|
||
const total = appState.groups.length;
|
||
const processed = appState.groups.filter(g => g.status === 'confirmed' || g.status === 'rejected').length;
|
||
const remaining = total - processed;
|
||
|
||
const remainingGroups = appState.groups.filter(g => g.status !== 'confirmed' && g.status !== 'rejected');
|
||
const sumInWork = remainingGroups.reduce((sum, g) => {
|
||
return sum + g.items.reduce((s, i) => s + i.amount, 0);
|
||
}, 0);
|
||
|
||
document.getElementById('kpi-total').textContent = total;
|
||
document.getElementById('kpi-processed').textContent = processed;
|
||
document.getElementById('kpi-remaining').textContent = remaining;
|
||
document.getElementById('kpi-sum').textContent = formatAmount(sumInWork);
|
||
}
|
||
|
||
// Рендер списка групп
|
||
function renderGroups() {
|
||
const container = document.getElementById('groups-container');
|
||
const emptyState = document.getElementById('empty-state');
|
||
const emptyTitle = document.getElementById('empty-title');
|
||
const emptyText = document.getElementById('empty-text');
|
||
const emptyIcon = document.getElementById('empty-icon');
|
||
const filtered = filterGroups();
|
||
|
||
if (filtered.length === 0) {
|
||
container.style.display = 'none';
|
||
emptyState.style.display = 'block';
|
||
document.getElementById('selection-panel').style.display = 'none';
|
||
|
||
// Определяем причину пустого состояния
|
||
const hasProcessedOnly = appState.groups.every(g => g.status === 'confirmed' || g.status === 'rejected');
|
||
if (hasProcessedOnly && appState.filters.status === 'all') {
|
||
emptyIcon.textContent = '✅';
|
||
emptyTitle.textContent = 'Все позиции разобраны';
|
||
emptyText.textContent = 'Нет групп для обработки';
|
||
} else if (appState.filters.period === 'q3' || appState.filters.period === 'q4') {
|
||
emptyIcon.textContent = '📭';
|
||
emptyTitle.textContent = 'Нет данных';
|
||
emptyText.textContent = `За выбранный период (${document.getElementById('filter-period').options[document.getElementById('filter-period').selectedIndex].text}) данных нет`;
|
||
} else if (appState.filters.status !== 'all') {
|
||
emptyIcon.textContent = '🔍';
|
||
emptyTitle.textContent = 'Ничего не найдено';
|
||
emptyText.textContent = `Нет групп со статусом "${document.getElementById('filter-status').options[document.getElementById('filter-status').selectedIndex].text}"`;
|
||
} else {
|
||
emptyIcon.textContent = '📭';
|
||
emptyTitle.textContent = 'Нет данных';
|
||
emptyText.textContent = 'Нет групп для обработки с выбранными фильтрами';
|
||
}
|
||
return;
|
||
}
|
||
|
||
emptyState.style.display = 'none';
|
||
container.style.display = 'flex';
|
||
|
||
let html = '';
|
||
filtered.forEach((group, index) => {
|
||
html += renderGroup(group, index);
|
||
});
|
||
container.innerHTML = html;
|
||
|
||
updateKPI();
|
||
attachEventListeners();
|
||
}
|
||
|
||
// Прикрепление обработчиков
|
||
function attachEventListeners() {
|
||
document.querySelectorAll('.item-checkbox').forEach(cb => {
|
||
cb.addEventListener('change', function() {
|
||
const groupEl = this.closest('.group-item');
|
||
const groupId = groupEl.dataset.group;
|
||
const itemIndex = groupEl.dataset.item;
|
||
const key = `${groupId}-${itemIndex}`;
|
||
|
||
if (this.checked) {
|
||
appState.selectedItems.add(key);
|
||
groupEl.classList.add('selected');
|
||
} else {
|
||
appState.selectedItems.delete(key);
|
||
groupEl.classList.remove('selected');
|
||
}
|
||
updateSelectionPanel();
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.btn-reject-group').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const groupId = this.dataset.group;
|
||
processGroup(groupId, 'rejected', 'Отклонено массово');
|
||
});
|
||
});
|
||
|
||
document.querySelectorAll('.btn-confirm-group').forEach(btn => {
|
||
btn.addEventListener('click', function() {
|
||
const groupId = this.dataset.group;
|
||
processGroup(groupId, 'confirmed', 'Подтверждено массово');
|
||
});
|
||
});
|
||
}
|
||
|
||
// Обновление панели выбора
|
||
function updateSelectionPanel() {
|
||
const panel = document.getElementById('selection-panel');
|
||
const count = appState.selectedItems.size;
|
||
|
||
if (count === 0) {
|
||
panel.style.display = 'none';
|
||
return;
|
||
}
|
||
|
||
panel.style.display = 'block';
|
||
document.getElementById('selected-count').textContent = count;
|
||
|
||
// Пересоздаём обработчики кнопок каждый раз
|
||
const btnConfirm = document.getElementById('btn-confirm');
|
||
const btnReject = document.getElementById('btn-reject');
|
||
|
||
// Клонируем кнопки, чтобы сбросить старые обработчики
|
||
const newConfirm = btnConfirm.cloneNode(true);
|
||
const newReject = btnReject.cloneNode(true);
|
||
|
||
btnConfirm.parentNode.replaceChild(newConfirm, btnConfirm);
|
||
btnReject.parentNode.replaceChild(newReject, btnReject);
|
||
|
||
// Вешаем новые обработчики
|
||
newConfirm.addEventListener('click', function() {
|
||
if (appState.selectedItems.size === 0) return;
|
||
const comment = document.getElementById('group-comment').value || 'Подтверждено пользователем';
|
||
const processedGroups = new Set();
|
||
appState.selectedItems.forEach(key => {
|
||
const [groupId, itemIndex] = key.split('-');
|
||
const group = appState.groups.find(g => g.id === groupId);
|
||
if (group && group.status === 'new') {
|
||
group.status = 'confirmed';
|
||
group.comment = comment;
|
||
processedGroups.add(groupId);
|
||
}
|
||
});
|
||
appState.selectedItems.clear();
|
||
saveState();
|
||
renderGroups();
|
||
document.getElementById('group-comment').value = '';
|
||
});
|
||
|
||
newReject.addEventListener('click', function() {
|
||
if (appState.selectedItems.size === 0) return;
|
||
const comment = document.getElementById('group-comment').value || 'Отклонено пользователем';
|
||
const processedGroups = new Set();
|
||
appState.selectedItems.forEach(key => {
|
||
const [groupId, itemIndex] = key.split('-');
|
||
const group = appState.groups.find(g => g.id === groupId);
|
||
if (group && group.status === 'new') {
|
||
group.status = 'rejected';
|
||
group.comment = comment;
|
||
processedGroups.add(groupId);
|
||
}
|
||
});
|
||
appState.selectedItems.clear();
|
||
saveState();
|
||
renderGroups();
|
||
document.getElementById('group-comment').value = '';
|
||
});
|
||
}
|
||
|
||
// Обработка группы
|
||
function processGroup(groupId, status, defaultComment) {
|
||
const group = appState.groups.find(g => g.id === groupId);
|
||
if (!group) return;
|
||
|
||
const comment = document.getElementById('group-comment').value || defaultComment;
|
||
group.status = status;
|
||
group.comment = comment;
|
||
|
||
appState.selectedItems.clear();
|
||
saveState();
|
||
renderGroups();
|
||
document.getElementById('group-comment').value = '';
|
||
}
|
||
|
||
// Сброс фильтров
|
||
function resetFilters() {
|
||
appState.filters = { category: 'all', status: 'all', period: 'all' };
|
||
document.getElementById('filter-category').value = 'all';
|
||
document.getElementById('filter-status').value = 'all';
|
||
document.getElementById('filter-period').value = 'all';
|
||
saveState();
|
||
renderGroups();
|
||
}
|
||
|
||
// Инициализация
|
||
document.addEventListener('DOMContentLoaded', function() {
|
||
loadState();
|
||
renderGroups();
|
||
|
||
document.getElementById('filter-category').addEventListener('change', function() {
|
||
appState.filters.category = this.value;
|
||
saveState();
|
||
renderGroups();
|
||
});
|
||
|
||
document.getElementById('filter-status').addEventListener('change', function() {
|
||
appState.filters.status = this.value;
|
||
saveState();
|
||
renderGroups();
|
||
});
|
||
|
||
document.getElementById('filter-period').addEventListener('change', function() {
|
||
appState.filters.period = this.value;
|
||
saveState();
|
||
renderGroups();
|
||
});
|
||
|
||
document.getElementById('btn-reset').addEventListener('click', resetFilters);
|
||
|
||
document.getElementById('btn-clear-storage').addEventListener('click', function() {
|
||
if (confirm('Очистить все сохранённые данные и загрузить демо-данные заново?')) {
|
||
localStorage.removeItem('zakupki-review-state');
|
||
appState.groups = JSON.parse(JSON.stringify(DEMO_DATA));
|
||
appState.selectedItems.clear();
|
||
appState.filters = { category: 'all', status: 'all', period: 'all' };
|
||
document.getElementById('filter-category').value = 'all';
|
||
document.getElementById('filter-status').value = 'all';
|
||
document.getElementById('filter-period').value = 'all';
|
||
renderGroups();
|
||
}
|
||
});
|
||
|
||
// Первичная инициализация обработчиков кнопок
|
||
updateSelectionPanel();
|
||
});
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|