Рефакторинг структуры проекта: шаблоны, статика и модули приложения

This commit is contained in:
2026-04-16 15:55:44 +00:00
parent d3fdc65116
commit 254a52fd47
15 changed files with 1998 additions and 1980 deletions

77
static/css/admin.css Normal file
View File

@@ -0,0 +1,77 @@
:root { --b:#1f4ea3; --line:#cfe0ff; }
* { box-sizing: border-box; }
body { margin:0; font-family:Manrope,sans-serif; background:#f0f5ff; color:#1a2746; }
body.ib { background:#fff1f1; }
.wrap { width:min(1600px, calc(100% - 24px)); margin:12px auto 24px; }
.top {
background: linear-gradient(130deg, #1f4ea3, #3977df);
border-radius: 14px;
color:#fff;
padding:14px 16px;
display:flex;
justify-content:space-between;
align-items:center;
gap:10px;
}
body.ib .top { background: linear-gradient(130deg, #9b2f3a, #c24a56); }
.scope-switch { display:flex; gap:8px; }
.scope-chip {
display:inline-block;
padding:7px 11px;
border-radius:9px;
text-decoration:none;
font-weight:700;
background:#e7efff;
color:#1f3f77;
border:1px solid #ccdcff;
}
.scope-chip.active { background:#fff; color:#112847; }
.grid { display:grid; grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); gap:10px; margin:12px 0; }
.box {
background:#fff;
border:1px solid #d4e3ff;
border-radius:12px;
padding:12px;
overflow: hidden;
}
form.inline { display:flex; gap:8px; flex-wrap: wrap; }
.inline > * { min-width: 0; }
.inline-product {
display:grid;
grid-template-columns: minmax(130px, 1fr) minmax(150px, 1fr) minmax(180px, 1.2fr) auto;
align-items:center;
gap:8px;
}
.inline-product button { white-space: nowrap; }
@media (max-width: 1300px) {
.inline-product { grid-template-columns: 1fr 1fr; }
}
@media (max-width: 720px) {
.inline-product { grid-template-columns: 1fr; }
}
input[type="text"] { flex:1; padding:9px 10px; border:1px solid #c6d8fb; border-radius:9px; }
button { border:0; border-radius:9px; padding:9px 11px; cursor:pointer; font-weight:700; }
.pri { background:#1f4ea3; color:#fff; }
.warn { background:#e8eefc; color:#223963; }
.danger { background:#ffefef; color:#8e1d1d; }
.lists { display:grid; grid-template-columns:1fr 1fr 1fr; gap:10px; margin-bottom:10px; }
.list-box { max-height: 430px; overflow-y: auto; padding-right: 4px; }
.list-box::-webkit-scrollbar { width:12px; }
.list-box::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
.list-item { display:flex; justify-content:space-between; align-items:center; gap:8px; border:1px solid var(--line); border-radius:10px; padding:6px 8px; margin-bottom:6px; background:#fff; min-height: 36px; }
.matrix-wrap { background:#fff; border:1px solid #d4e3ff; border-radius:12px; padding:10px; }
.matrix-scroll { overflow:auto; max-height:72vh; border:1px solid #dce7ff; border-radius:10px; }
.matrix-scroll::-webkit-scrollbar,
.matrix-h-scroll::-webkit-scrollbar { height:24px; width:14px; }
.matrix-scroll::-webkit-scrollbar-thumb,
.matrix-h-scroll::-webkit-scrollbar-thumb { background:#bfd4ff; border-radius:10px; }
.matrix-h-scroll { overflow-x:auto; overflow-y:hidden; height:28px; margin:8px 0 10px; border:1px solid #dce7ff; border-radius:10px; background:#f6f9ff; }
.matrix-h-scroll-inner { height:1px; }
table { border-collapse: collapse; min-width: 1200px; width:max-content; }
th, td { border:1px solid var(--line); padding:6px; font-size:12px; text-align:center; }
th { position: sticky; top: 0; background:#eaf2ff; z-index: 2; }
th:first-child, td:first-child { position: sticky; left:0; background:#eef5ff; z-index: 1; text-align:left; min-width: 280px; }
th:first-child { z-index: 3; }
td input { transform: scale(1.05); }
.matrix-tip { margin:0 0 6px; font-size:12px; color:#37507d; }

349
static/css/index.css Normal file
View File

@@ -0,0 +1,349 @@
:root {
--bg: #eef4ff;
--bg2: #dde9ff;
--panel: #ffffff;
--text: #15203b;
--muted: #526079;
--line: #c8d7f7;
--brand: #1f4ea3;
--brand-2: #3578ef;
--accent: #0f7b56;
--radius: 18px;
--shadow: 0 20px 55px rgba(16, 43, 95, .14);
}
* { box-sizing: border-box; }
body {
margin: 0;
min-height: 100vh;
font-family: Manrope, sans-serif;
color: var(--text);
background:
radial-gradient(1200px 700px at -10% -10%, #c8dbff 0%, transparent 55%),
radial-gradient(900px 500px at 110% -20%, #d8f4ec 0%, transparent 50%),
linear-gradient(160deg, var(--bg) 0%, var(--bg2) 100%);
}
body.scope-ib {
background:
radial-gradient(1200px 700px at -10% -10%, #ffb3b3 0%, transparent 58%),
radial-gradient(900px 500px at 110% -20%, #ffbeb3 0%, transparent 52%),
linear-gradient(160deg, #ffd7d7 0%, #ffb8b8 100%);
}
body.scope-ib .hero {
background:
linear-gradient(160deg, rgba(255,255,255,.09), rgba(255,255,255,0) 45%),
linear-gradient(125deg, #7a1f2a 0%, #b43444 55%, #d34d57 100%);
}
body.scope-ib .hero::after {
background-color: rgba(90, 15, 28, .45);
}
body.scope-ib .hero::before {
background:
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
rgba(74, 9, 24, .42);
}
body.scope-ib .mode-btn.active {
background: linear-gradient(140deg, #9a2331, #c03d4c);
box-shadow: 0 8px 18px rgba(158, 33, 51, .35);
}
.wrap {
width: min(1400px, calc(100% - 32px));
margin: 18px auto 28px;
}
.brand-strip {
margin-bottom: 12px;
display: flex;
justify-content: flex-start;
}
.hero {
background:
linear-gradient(160deg, rgba(255,255,255,.12), rgba(255,255,255,0) 45%),
linear-gradient(125deg, #173d83 0%, #2c63ca 55%, #3f83ff 100%);
color: #f5f8ff;
border-radius: calc(var(--radius) + 4px);
padding: 26px;
box-shadow: var(--shadow);
position: relative;
overflow: hidden;
}
.hero-layout {
display: grid;
grid-template-columns: 1fr;
gap: 0;
align-items: center;
position: relative;
z-index: 1;
}
.hero::after {
content: "";
position: absolute;
inset: auto -4% 0 -4%;
height: 62%;
background-color: rgba(17, 46, 102, .38);
clip-path: polygon(0 100%, 0 84%, 9% 52%, 17% 70%, 28% 42%, 39% 73%, 50% 32%, 61% 66%, 73% 38%, 84% 69%, 93% 47%, 100% 60%, 100% 100%);
}
.hero::before {
content: "";
position: absolute;
inset: auto -3% 0 -3%;
height: 50%;
background:
linear-gradient(to top, rgba(255,255,255,.9) 0 2px, rgba(255,255,255,0) 2px),
rgba(9, 31, 76, .32);
clip-path: polygon(0 100%, 0 90%, 8% 67%, 16% 82%, 25% 58%, 35% 85%, 47% 50%, 58% 80%, 69% 55%, 80% 79%, 91% 62%, 100% 74%, 100% 100%);
}
.hero h1 {
margin: 0 0 8px;
font-family: "Space Grotesk", sans-serif;
font-size: clamp(26px, 4.8vw, 48px);
letter-spacing: .3px;
line-height: 1.02;
}
.brand-logo {
width: clamp(170px, 24vw, 280px);
padding: 0;
border: 0;
background: transparent;
box-shadow: none;
}
.brand-logo img {
width: 100%;
height: auto;
display: block;
object-fit: contain;
filter: drop-shadow(0 6px 10px rgba(0, 0, 0, .14));
}
.hero p {
margin: 0;
max-width: 860px;
color: rgba(245,248,255,.92);
font-size: 16px;
}
.board {
margin-top: 18px;
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.mode-switch {
display: inline-flex;
gap: 6px;
padding: 6px;
border-radius: 14px;
background: #ffffff;
border: 1px solid #d9e6ff;
box-shadow: 0 8px 24px rgba(26, 58, 118, .08);
margin-top: 12px;
}
.mode-btn {
border: 0;
border-radius: 10px;
padding: 9px 14px;
font-size: 13px;
font-weight: 800;
letter-spacing: .2px;
color: #2a4e8d;
background: transparent;
cursor: pointer;
transition: .18s ease;
}
.mode-btn:hover { background: #eef4ff; }
.mode-btn.active {
color: #fff;
background: linear-gradient(140deg, #1f4ea3, #3978e0);
box-shadow: 0 8px 18px rgba(38, 86, 176, .28);
}
.card {
background: var(--panel);
border: 1px solid #dfebff;
border-radius: var(--radius);
box-shadow: 0 10px 30px rgba(24, 56, 116, .08);
padding: 14px;
min-height: 320px;
}
.card h2 {
margin: 0 0 10px;
font-size: 14px;
text-transform: uppercase;
letter-spacing: .9px;
color: #234782;
font-weight: 800;
}
.search {
width: 100%;
border: 1px solid #cfe0ff;
border-radius: 12px;
padding: 11px 12px;
font-size: 14px;
margin-bottom: 10px;
outline: none;
transition: .2s ease;
}
.search:focus {
border-color: #5b91f6;
box-shadow: 0 0 0 4px rgba(91,145,246,.14);
}
.chip-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
max-height: 380px;
overflow: auto;
padding-right: 4px;
}
.chip {
border: 1px solid #ccdbf7;
border-radius: 999px;
padding: 8px 12px;
background: #f7faff;
color: #22427a;
font-size: 13px;
cursor: pointer;
user-select: none;
transition: .18s ease;
}
.chip:hover {
transform: translateY(-1px);
border-color: #98b9ef;
background: #f0f6ff;
}
.chip.active {
background: linear-gradient(140deg, #1f4ea3, #3978e0);
border-color: transparent;
color: #fff;
box-shadow: 0 6px 16px rgba(34,83,172,.25);
}
.chip.dim {
opacity: .45;
}
.footer-bar {
margin-top: 14px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
color: var(--muted);
font-size: 13px;
}
button.action {
border: 0;
border-radius: 10px;
background: linear-gradient(140deg, #0d7e59, #0a6648);
color: #fff;
padding: 9px 14px;
font-weight: 700;
cursor: pointer;
}
.result {
margin-top: 16px;
background: #fff;
border-radius: var(--radius);
border: 1px solid #dfebff;
box-shadow: 0 10px 30px rgba(24, 56, 116, .07);
padding: 14px;
}
.result-head {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 12px;
gap: 12px;
}
.result-head h3 {
margin: 0;
font-size: 17px;
color: #1f3f77;
}
.rows {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(290px, 1fr));
gap: 12px;
}
.row-card {
border: 1px solid var(--line);
background: linear-gradient(180deg, #ffffff, #f5f9ff);
border-radius: 12px;
padding: 10px;
}
.row-card strong { color: #1a3e79; font-size: 14px; }
.tags { margin-top: 8px; display: flex; flex-wrap: wrap; gap: 6px; }
.tag {
font-size: 12px;
border-radius: 999px;
background: #eaf2ff;
color: #234b89;
padding: 4px 8px;
border: 1px solid #d2e3ff;
}
a.tag {
text-decoration: none;
display: inline-flex;
align-items: center;
gap: 5px;
}
a.tag::after {
content: "↗";
font-size: 11px;
opacity: .85;
}
.credit {
position: fixed;
right: 16px;
bottom: 10px;
text-align: right;
line-height: 1.1;
z-index: 5;
}
.credit .name {
font-family: Caveat, cursive;
font-size: 14px;
color: #1c3f7c;
}
.credit a {
font-size: 7px;
color: #2f5fae;
text-decoration: none;
font-weight: 700;
}
@media (max-width: 980px) {
.brand-logo { max-width: 240px; }
.board { grid-template-columns: 1fr; }
.hero { padding: 20px; }
.credit { right: 8px; bottom: 6px; }
.credit .name { font-size: 8px; }
.credit a { font-size: 6px; }
}

37
static/css/login.css Normal file
View File

@@ -0,0 +1,37 @@
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: linear-gradient(135deg, #dbe8ff, #eff5ff);
font-family: Manrope, sans-serif;
}
form {
width: min(430px, calc(100% - 24px));
background: #fff;
border-radius: 16px;
border: 1px solid #d5e3fb;
padding: 22px;
box-shadow: 0 16px 36px rgba(22,61,126,.13);
}
h1 { margin: 0 0 14px; color: #1f4ea3; font-size: 22px; }
input {
width: 100%;
padding: 10px 12px;
margin-bottom: 10px;
border: 1px solid #c8daf8;
border-radius: 10px;
font-size: 14px;
box-sizing: border-box;
}
button {
width: 100%;
padding: 10px;
border: 0;
border-radius: 10px;
color: #fff;
font-weight: 700;
background: linear-gradient(130deg, #1f4ea3, #3775de);
cursor: pointer;
}
.error { color: #a02020; margin: 0 0 10px; font-size: 14px; }

92
static/js/admin.js Normal file
View File

@@ -0,0 +1,92 @@
(function () {
const matrixForm = document.getElementById("matrixForm");
const matrixScroll = document.getElementById("matrixScroll");
const matrixTable = document.getElementById("matrixTable");
const topScroll = document.getElementById("matrixHScroll");
const topScrollInner = document.getElementById("matrixHScrollInner");
if (!matrixForm || !matrixScroll || !matrixTable || !topScroll || !topScrollInner) return;
let isDirty = false;
let syncing = false;
let saveTimer = null;
let saveInFlight = false;
function markDirty() {
isDirty = true;
}
function updateTopScrollWidth() {
topScrollInner.style.width = matrixTable.scrollWidth + "px";
}
function syncScrollFromTop() {
if (syncing) return;
syncing = true;
matrixScroll.scrollLeft = topScroll.scrollLeft;
syncing = false;
}
function syncScrollFromMatrix() {
if (syncing) return;
syncing = true;
topScroll.scrollLeft = matrixScroll.scrollLeft;
syncing = false;
}
async function autoSaveMatrix() {
if (saveInFlight) return;
saveInFlight = true;
try {
const formData = new FormData(matrixForm);
const response = await fetch(window.location.href, {
method: "POST",
body: formData,
credentials: "same-origin",
});
if (!response.ok) throw new Error("save failed");
isDirty = false;
} catch (error) {
} finally {
saveInFlight = false;
}
}
matrixForm.addEventListener("change", (event) => {
if (!(event.target && event.target.matches('input[type="checkbox"]'))) return;
markDirty();
if (saveTimer) clearTimeout(saveTimer);
saveTimer = setTimeout(autoSaveMatrix, 250);
});
matrixForm.addEventListener("submit", () => {
isDirty = false;
});
window.addEventListener("beforeunload", (event) => {
if (!isDirty) return;
event.preventDefault();
event.returnValue = "";
});
document.addEventListener("click", (event) => {
const anchor = event.target.closest("a");
if (!anchor || !isDirty) return;
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
if (!ok) return;
event.preventDefault();
});
document.addEventListener("submit", (event) => {
const form = event.target;
if (!form || form === matrixForm || !isDirty) return;
const ok = window.confirm("Есть несохраненные изменения матрицы. Нажмите OK, чтобы остаться и сначала сохранить.");
if (!ok) return;
event.preventDefault();
});
topScroll.addEventListener("scroll", syncScrollFromTop);
matrixScroll.addEventListener("scroll", syncScrollFromMatrix);
window.addEventListener("resize", updateTopScrollWidth);
updateTopScrollWidth();
syncScrollFromMatrix();
})();

271
static/js/index.js Normal file
View File

@@ -0,0 +1,271 @@
const state = {
data: { vendors: [], categories: [], products: [], product_links: [], links: [] },
scope: "infra",
selectedVendors: new Set(),
selectedCategories: new Set(),
vendorSearch: "",
categorySearch: "",
};
const el = {
vendorList: document.getElementById("vendorList"),
categoryList: document.getElementById("categoryList"),
vendorSearch: document.getElementById("vendorSearch"),
categorySearch: document.getElementById("categorySearch"),
resultRows: document.getElementById("resultRows"),
stats: document.getElementById("stats"),
clearBtn: document.getElementById("clearBtn"),
modeInfra: document.getElementById("modeInfra"),
modeIb: document.getElementById("modeIb"),
};
let clickAudioCtx = null;
function playScopeClick() {
try {
if (!clickAudioCtx) {
clickAudioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
const now = clickAudioCtx.currentTime;
const osc = clickAudioCtx.createOscillator();
const gain = clickAudioCtx.createGain();
osc.type = "triangle";
osc.frequency.setValueAtTime(1800, now);
osc.frequency.exponentialRampToValueAtTime(950, now + 0.025);
gain.gain.setValueAtTime(0.0001, now);
gain.gain.linearRampToValueAtTime(0.14, now + 0.0025);
gain.gain.exponentialRampToValueAtTime(0.0001, now + 0.04);
osc.connect(gain);
gain.connect(clickAudioCtx.destination);
osc.start(now);
osc.stop(now + 0.045);
} catch (_) {
// Ignore audio failures silently.
}
}
function normalize(s) {
return s.toLowerCase().replace(/ё/g, "е");
}
function getMaps() {
const productsByVendor = new Map();
const categoriesByProduct = new Map();
const productsByCategory = new Map();
for (const v of state.data.vendors) productsByVendor.set(v.id, new Set());
for (const p of state.data.products) categoriesByProduct.set(p.id, new Set());
for (const c of state.data.categories) productsByCategory.set(c.id, new Set());
for (const p of state.data.products) {
if (!productsByVendor.has(p.vendor_id)) productsByVendor.set(p.vendor_id, new Set());
productsByVendor.get(p.vendor_id).add(p.id);
}
for (const l of state.data.product_links) {
if (!categoriesByProduct.has(l.product_id)) categoriesByProduct.set(l.product_id, new Set());
if (!productsByCategory.has(l.category_id)) productsByCategory.set(l.category_id, new Set());
categoriesByProduct.get(l.product_id).add(l.category_id);
productsByCategory.get(l.category_id).add(l.product_id);
}
return { productsByVendor, categoriesByProduct, productsByCategory };
}
function visibleSets() {
const { productsByVendor, categoriesByProduct, productsByCategory } = getMaps();
const allowedCategories = new Set(state.data.categories.map(c => c.id));
const allowedVendors = new Set(state.data.vendors.map(v => v.id));
const allowedProducts = new Set(state.data.products.map(p => p.id));
if (state.selectedVendors.size > 0) {
const fromVendorProducts = new Set();
for (const vId of state.selectedVendors) {
for (const pId of (productsByVendor.get(vId) || [])) fromVendorProducts.add(pId);
}
for (const pId of Array.from(allowedProducts)) {
if (!fromVendorProducts.has(pId)) allowedProducts.delete(pId);
}
}
if (state.selectedCategories.size > 0) {
const fromCategories = new Set();
for (const cId of state.selectedCategories) {
for (const pId of (productsByCategory.get(cId) || [])) fromCategories.add(pId);
}
for (const pId of Array.from(allowedProducts)) {
if (!fromCategories.has(pId)) allowedProducts.delete(pId);
}
}
for (const cId of Array.from(allowedCategories)) {
const products = productsByCategory.get(cId) || new Set();
if (![...products].some(pId => allowedProducts.has(pId))) {
allowedCategories.delete(cId);
}
}
for (const vId of Array.from(allowedVendors)) {
const products = productsByVendor.get(vId) || new Set();
if (![...products].some(pId => allowedProducts.has(pId))) {
allowedVendors.delete(vId);
}
}
return { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct };
}
function renderChips() {
const { allowedCategories, allowedVendors } = visibleSets();
const vendorQ = normalize(state.vendorSearch);
const categoryQ = normalize(state.categorySearch);
el.vendorList.innerHTML = "";
for (const vendor of state.data.vendors) {
if (vendorQ && !normalize(vendor.name).includes(vendorQ)) continue;
const node = document.createElement("button");
node.className = "chip";
if (state.selectedVendors.has(vendor.id)) node.classList.add("active");
else if (!allowedVendors.has(vendor.id)) node.classList.add("dim");
node.textContent = vendor.name;
node.addEventListener("click", () => {
if (state.selectedVendors.has(vendor.id)) state.selectedVendors.delete(vendor.id);
else state.selectedVendors.add(vendor.id);
render();
});
el.vendorList.appendChild(node);
}
el.categoryList.innerHTML = "";
const showOnlyLinkedCategories = state.selectedVendors.size > 0;
for (const category of state.data.categories) {
if (categoryQ && !normalize(category.name).includes(categoryQ)) continue;
if (showOnlyLinkedCategories && !allowedCategories.has(category.id) && !state.selectedCategories.has(category.id)) continue;
const node = document.createElement("button");
node.className = "chip";
if (state.selectedCategories.has(category.id)) node.classList.add("active");
else if (!allowedCategories.has(category.id)) node.classList.add("dim");
node.textContent = category.name;
node.addEventListener("click", () => {
if (state.selectedCategories.has(category.id)) state.selectedCategories.delete(category.id);
else state.selectedCategories.add(category.id);
render();
});
el.categoryList.appendChild(node);
}
el.stats.textContent = `Вендоров: ${allowedVendors.size}/${state.data.vendors.length} | Категорий: ${allowedCategories.size}/${state.data.categories.length} | Продуктов: ${visibleSets().allowedProducts.size}/${state.data.products.length}`;
}
function renderResults() {
const { allowedCategories, allowedVendors, allowedProducts, productsByVendor, categoriesByProduct } = visibleSets();
const productsById = new Map(state.data.products.map(p => [p.id, p]));
const rows = [];
for (const vendor of state.data.vendors) {
if (!allowedVendors.has(vendor.id)) continue;
const productIds = [...(productsByVendor.get(vendor.id) || [])]
.filter(pId => allowedProducts.has(pId))
.filter(pId => {
if (state.selectedCategories.size === 0) return true;
const cats = categoriesByProduct.get(pId) || new Set();
return [...state.selectedCategories].some(cId => cats.has(cId));
});
const products = productIds.map(pId => productsById.get(pId)).filter(Boolean);
if (products.length === 0) continue;
rows.push({ vendor: vendor.name, products });
}
el.resultRows.innerHTML = "";
if (rows.length === 0) {
el.resultRows.innerHTML = '<div class="row-card"><strong>По текущим фильтрам ничего не найдено</strong></div>';
return;
}
for (const row of rows) {
const card = document.createElement("article");
card.className = "row-card";
const title = document.createElement("strong");
title.textContent = row.vendor;
card.appendChild(title);
const tags = document.createElement("div");
tags.className = "tags";
for (const product of row.products) {
const hasUrl = product.url && String(product.url).trim().length > 0;
const tag = document.createElement(hasUrl ? "a" : "span");
tag.className = "tag";
tag.textContent = product.name;
if (hasUrl) {
tag.href = product.url;
tag.target = "_blank";
tag.rel = "noopener noreferrer";
}
tags.appendChild(tag);
}
card.appendChild(tags);
el.resultRows.appendChild(card);
}
}
function render() {
el.modeInfra.classList.toggle("active", state.scope === "infra");
el.modeIb.classList.toggle("active", state.scope === "ib");
document.body.classList.toggle("scope-ib", state.scope === "ib");
renderChips();
renderResults();
}
async function loadScopeData(scope) {
const res = await fetch(`/api/data?scope=${encodeURIComponent(scope)}`);
state.data = await res.json();
}
async function init() {
await loadScopeData(state.scope);
render();
}
async function switchScope(scope) {
if (scope === state.scope) return;
playScopeClick();
state.scope = scope;
state.selectedVendors.clear();
state.selectedCategories.clear();
state.vendorSearch = "";
state.categorySearch = "";
el.vendorSearch.value = "";
el.categorySearch.value = "";
await loadScopeData(scope);
render();
}
el.vendorSearch.addEventListener("input", e => {
state.vendorSearch = e.target.value || "";
render();
});
el.categorySearch.addEventListener("input", e => {
state.categorySearch = e.target.value || "";
render();
});
el.clearBtn.addEventListener("click", () => {
state.selectedVendors.clear();
state.selectedCategories.clear();
state.vendorSearch = "";
state.categorySearch = "";
el.vendorSearch.value = "";
el.categorySearch.value = "";
render();
});
el.modeInfra.addEventListener("click", () => {
switchScope("infra");
});
el.modeIb.addEventListener("click", () => {
switchScope("ib");
});
init();