diff --git a/HISTORY.md b/HISTORY.md index 0de1be2..b9eea32 100644 --- a/HISTORY.md +++ b/HISTORY.md @@ -1,5 +1,15 @@ # История изменений +## 2026-04-06 18:00 UTC – Многостраничный UI: загрузка на data.html + +**Проблема:** Источник данных и кнопка загрузки должны быть на отдельной странице; на ней же — сводка по числу объектов в БД и по последней загрузке. + +**Решение:** Вынесены стили в `css/dashboard.css`; добавлены `data.html` (загрузка, блоки статистики, `upload-core.js`) и навигация в шапке между `index.html` и `data.html`. С главной убраны PapaParse/XLSX и логика выбора файла. + +**Проверка:** Открытие `/data.html`, загрузка `sample-data.csv`, проверка счётчиков и переход на мониторинг. + +--- + ## 2026-04-06 17:00 UTC – Проверка docker compose api и Nginx для /api/ **Проблема:** Запросы к `https://sd.gen7x.ru/api/health` отдавали 404: регекс статики `~* \.(json|…)$` перехватывал URI раньше префикса `/api/`. diff --git a/README.md b/README.md index cf1178c..4aad486 100644 --- a/README.md +++ b/README.md @@ -4,7 +4,16 @@ **Публичный URL (прод):** https://sd.gen7x.ru/ — статика и API через Nginx (`/opt/gen7x/nginx/conf.d/sd.gen7x.ru.conf`). -Дашборд инцидентов ServiceDesk: KPI, диаграммы, таблица, разбивка по сотрудникам. Загрузка файла (CSV, JSON, Excel) **сохраняется в PostgreSQL**: ключ записи — целое число из поля **«Название»**; при совпадении ключей строка **обновляется**, иначе **добавляется**. При открытии страницы данные **подгружаются из базы** (если API доступен). +Многостраничный интерфейс: + +| Страница | Назначение | +|----------|------------| +| [`index.html`](index.html) | Мониторинг: KPI, фильтры, вкладки (графики, таблица, сотрудники). Данные из БД при открытии. | +| [`data.html`](data.html) | Источник данных: загрузка CSV/JSON/Excel, счётчики «объектов в базе», последней загрузки, пропущенных строк. | + +Общие стили: [`css/dashboard.css`](css/dashboard.css). Разбор файла и API: [`js/upload-core.js`](js/upload-core.js). + +Загрузка файла **сохраняется в PostgreSQL** по ключу **«Название»** (обновление / вставка). На главной данные **подгружаются из базы** при открытии (если API доступен). ## База данных и API @@ -32,7 +41,7 @@ docker compose up -d api ### Локально (только UI) -Откройте [`index.html`](index.html). Без API сохранение в БД недоступно (будет сообщение об ошибке после разбора файла). Пример данных: [`sample-data.csv`](sample-data.csv). +Откройте [`index.html`](index.html) (мониторинг) или [`data.html`](data.html) (загрузка). Без API сохранение в БД на странице данных недоступно. Пример файла: [`sample-data.csv`](sample-data.csv). ### Docker (статика + опционально API) diff --git a/css/dashboard.css b/css/dashboard.css new file mode 100644 index 0000000..c3ff449 --- /dev/null +++ b/css/dashboard.css @@ -0,0 +1,1006 @@ + :root { + --bg: #eef1f6; + --bg-alt: #e4e9f0; + --surface: #f7f9fc; + --surface-2: #ffffff; + --rule: #cdd4de; + --rule-strong: #b0bbc9; + --rule-faint: #e0e5ed; + --navy: #0d2444; + --navy-mid: #1a3a6b; + --steel: #2c5282; + --sky: #1a73c8; + --sky-light: #e8f1fb; + --go: #0a7c4e; + --go-bg: #e6f5ef; + --warn: #b45309; + --warn-bg: #fef3e2; + --alert: #c0392b; + --alert-bg: #fdecea; + --info: #1565c0; + --info-bg: #e3edf9; + --text: #1c2b3a; + --text-2: #4a5f75; + --text-3: #7a8fa3; + --r: 4px; + --r-lg: 6px; + --shadow-sm: 0 1px 3px rgba(13, 36, 68, 0.08), 0 1px 2px rgba(13, 36, 68, 0.04); + --shadow: 0 2px 8px rgba(13, 36, 68, 0.1), 0 1px 3px rgba(13, 36, 68, 0.06); + --shadow-md: 0 4px 16px rgba(13, 36, 68, 0.12), 0 2px 6px rgba(13, 36, 68, 0.07); + } + + * { box-sizing: border-box; } + html, body { margin: 0; padding: 0; } + body { + font-family: "IBM Plex Sans", system-ui, sans-serif; + font-size: 13px; + color: var(--text); + background: var(--bg); + min-height: 100vh; + position: relative; + } + body::before { + content: ""; + position: fixed; + inset: 0; + z-index: 0; + pointer-events: none; + background-image: + linear-gradient(rgba(44, 82, 130, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(44, 82, 130, 0.04) 1px, transparent 1px); + background-size: 48px 48px; + } + + ::-webkit-scrollbar { width: 7px; height: 7px; } + ::-webkit-scrollbar-track { background: var(--bg-alt); } + ::-webkit-scrollbar-thumb { background: var(--rule-strong); border-radius: 4px; } + ::-webkit-scrollbar-thumb:hover { background: var(--steel); } + + .page { + max-width: 1480px; + margin: 0 auto; + position: relative; + z-index: 1; + } + + /* Topbar */ + .topbar { + height: 52px; + background: var(--navy); + display: flex; + align-items: stretch; + position: relative; + overflow: hidden; + } + .topbar::after { + content: ""; + position: absolute; + right: 320px; + width: 60px; + height: 100%; + background: var(--sky); + clip-path: polygon(16px 0, 100% 0, calc(100% - 16px) 100%, 0 100%); + opacity: 0.35; + pointer-events: none; + } + .topbar-brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 18px; + border-right: 1px solid rgba(255, 255, 255, 0.1); + position: relative; + z-index: 1; + } + .topbar-nav { + display: flex; + align-items: stretch; + gap: 0; + position: relative; + z-index: 1; + border-right: 1px solid rgba(255, 255, 255, 0.08); + } + .topbar-nav .nav-link { + font-family: "Barlow Condensed", sans-serif; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + color: rgba(255, 255, 255, 0.45); + text-decoration: none; + padding: 0 16px; + display: flex; + align-items: center; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + } + .topbar-nav .nav-link:hover { + color: rgba(255, 255, 255, 0.85); + background: rgba(255, 255, 255, 0.04); + } + .topbar-nav .nav-link.active { + color: #fff; + border-bottom-color: var(--sky); + } + .brand-hex { + width: 34px; + height: 34px; + background: var(--sky); + clip-path: polygon(50% 0%, 100% 25%, 100% 75%, 50% 100%, 0% 75%, 0% 25%); + flex-shrink: 0; + position: relative; + } + .brand-hex::after { + content: "◈"; + position: absolute; + inset: 0; + display: flex; + align-items: center; + justify-content: center; + color: #fff; + font-size: 16px; + line-height: 1; + } + .brand-text-main { + font-family: "Barlow Condensed", sans-serif; + font-size: 16px; + font-weight: 700; + letter-spacing: 2.5px; + text-transform: uppercase; + color: #fff; + line-height: 1.2; + } + .brand-text-sub { + font-family: "IBM Plex Mono", monospace; + font-size: 9px; + letter-spacing: 2px; + color: rgba(255, 255, 255, 0.45); + margin-top: 2px; + } + .topbar-seg { + display: flex; + flex-direction: column; + justify-content: center; + padding: 0 18px; + border-right: 1px solid rgba(255, 255, 255, 0.08); + position: relative; + z-index: 1; + } + .topbar-seg:last-child { margin-left: auto; border-right: none; } + .seg-label { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + color: rgba(255, 255, 255, 0.4); + } + .seg-value { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: rgba(255, 255, 255, 0.85); + margin-top: 2px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + } + .status-pip { + width: 7px; + height: 7px; + border-radius: 50%; + flex-shrink: 0; + } + .status-pip.pip-green { + background: #22c55e; + box-shadow: 0 0 6px rgba(34, 197, 94, 0.8); + } + #control-date { + background: rgba(255, 255, 255, 0.08); + border: 1px solid rgba(255, 255, 255, 0.25); + color: rgba(255, 255, 255, 0.9); + border-radius: var(--r); + padding: 2px 6px; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color-scheme: dark; + } + .btn-today { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + padding: 4px 10px; + border-radius: var(--r); + border: 1px solid var(--sky); + background: rgba(26, 115, 200, 0.6); + color: #fff; + cursor: pointer; + } + .btn-today:hover { background: rgba(26, 115, 200, 0.85); } + + /* Section head */ + .section-head { + height: 36px; + background: var(--navy-mid); + border-top: 2px solid var(--sky); + display: flex; + align-items: center; + padding: 0 20px; + gap: 12px; + } + .section-head-label { + font-family: "Barlow Condensed", sans-serif; + font-size: 11px; + font-weight: 700; + letter-spacing: 3px; + text-transform: uppercase; + color: rgba(255, 255, 255, 0.55); + } + .section-head-title { + font-family: "Barlow Condensed", sans-serif; + font-size: 14px; + font-weight: 700; + color: #fff; + } + .section-head-rule { + flex: 1; + height: 1px; + background: rgba(255, 255, 255, 0.1); + } + .section-head-info { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: rgba(255, 255, 255, 0.4); + } + + .main { padding: 16px 20px 32px; } + + /* Upload */ + .upload-panel { + background: #fff; + border: 1px solid var(--rule); + border-left: 3px solid var(--sky); + border-radius: var(--r); + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 14px; + padding: 12px 16px; + margin-bottom: 10px; + } + .upload-panel label.lbl { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-3); + } + .btn-upload { + font-family: "Barlow Condensed", sans-serif; + font-size: 12px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.5px; + padding: 8px 16px; + border: none; + border-radius: var(--r); + background: var(--steel); + color: #fff; + cursor: pointer; + } + .btn-upload:hover { background: var(--navy-mid); } + .upload-hint { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: var(--text-3); + } + + /* Messages */ + .msg { + display: none; + padding: 10px 14px; + margin-bottom: 10px; + border-radius: var(--r); + border-left: 3px solid; + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + } + .msg.show { display: block; } + .msg-ok { + background: var(--go-bg); + border-left-color: var(--go); + color: var(--text); + } + .msg-load { + background: var(--info-bg); + border-left-color: var(--sky); + color: var(--text); + position: relative; + padding-left: 36px; + } + .msg-load::before { + content: ""; + position: absolute; + left: 12px; + top: 50%; + margin-top: -9px; + width: 16px; + height: 16px; + border: 2px solid var(--rule); + border-top-color: var(--sky); + border-radius: 50%; + animation: spin 0.8s linear infinite; + } + .msg-err { + background: var(--alert-bg); + border-left-color: var(--alert); + color: var(--text); + } + @keyframes spin { to { transform: rotate(360deg); } } + + #dashboard-content { display: none; } + #dashboard-content.visible { display: block; } + + /* KPI */ + .kpi-strip { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 10px; + margin-bottom: 12px; + } + .kpi-card { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + overflow: hidden; + box-shadow: var(--shadow-sm); + transition: transform 0.15s ease, box-shadow 0.15s ease; + } + .kpi-card:hover { + transform: translateY(-1px); + box-shadow: var(--shadow); + } + .kpi-accent { height: 3px; } + .kpi-card.total .kpi-accent { background: var(--steel); } + .kpi-card.closed .kpi-accent { background: var(--go); } + .kpi-card.progress .kpi-accent { background: var(--warn); } + .kpi-card.overdue .kpi-accent { background: var(--alert); } + .kpi-card.overdue { + animation: kpi-pulse 2.8s ease-in-out infinite; + } + @keyframes kpi-pulse { + 0%, 100% { box-shadow: var(--shadow-sm); } + 50% { box-shadow: 0 0 0 3px rgba(192, 57, 43, 0.1), var(--shadow-sm); } + } + .kpi-body { padding: 12px 14px; } + .kpi-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 8px; + } + .kpi-num { + font-family: "Barlow Condensed", sans-serif; + font-size: 36px; + font-weight: 800; + line-height: 1; + } + .kpi-card.total .kpi-num { color: var(--steel); } + .kpi-card.closed .kpi-num { color: var(--go); } + .kpi-card.progress .kpi-num { color: var(--warn); } + .kpi-card.overdue .kpi-num { color: var(--alert); } + .kpi-label { + font-family: "Barlow Condensed", sans-serif; + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 2px; + color: var(--text-3); + margin-top: 6px; + } + .kpi-sub { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: var(--text-3); + margin-top: 4px; + } + .kpi-icon { + font-family: "Barlow Condensed", sans-serif; + font-size: 28px; + font-weight: 600; + opacity: 0.07; + line-height: 1; + user-select: none; + } + + /* Filters */ + .filters-bar { + background: #fff; + border: 1px solid var(--rule); + box-shadow: var(--shadow-sm); + padding: 8px 14px; + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: 10px; + margin-bottom: 12px; + border-radius: var(--r); + } + .filters-bar > .lbl { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + color: var(--text-3); + padding-top: 9px; + } + .search-wrap { + flex: 1; + min-width: 200px; + position: relative; + } + .search-wrap::before { + content: "⌕"; + position: absolute; + left: 10px; + top: 50%; + transform: translateY(-50%); + font-size: 14px; + color: var(--text-3); + pointer-events: none; + } + .search-wrap input { + width: 100%; + padding: 8px 12px 8px 32px; + border: 1px solid var(--rule); + border-radius: var(--r); + background: var(--bg); + font-family: "IBM Plex Sans", sans-serif; + font-size: 12px; + color: var(--text); + } + .search-wrap input:focus { + outline: none; + border-color: var(--sky); + box-shadow: 0 0 0 3px rgba(26, 115, 200, 0.1); + } + + .filter-group { + min-width: 150px; + position: relative; + } + .filter-select { + width: 100%; + padding: 8px 12px; + border: 1px solid var(--rule); + border-radius: var(--r); + background: var(--bg); + font-family: "IBM Plex Sans", sans-serif; + font-size: 12px; + color: var(--text-2); + cursor: pointer; + text-align: left; + } + .filter-select:hover { border-color: var(--rule-strong); } + .filter-options { + display: none; + position: absolute; + left: 0; + right: 0; + top: 100%; + margin-top: 4px; + background: #fff; + border: 1px solid var(--rule-strong); + border-radius: var(--r); + box-shadow: var(--shadow-md); + max-height: 210px; + overflow-y: auto; + z-index: 50; + padding: 8px 0; + } + .filter-options.show { display: block; } + .filter-options label { + display: flex; + align-items: center; + gap: 8px; + padding: 6px 12px; + font-size: 12px; + cursor: pointer; + color: var(--text-2); + } + .filter-options label:hover { background: var(--sky-light); } + .filter-options input[type="checkbox"] { + accent-color: var(--sky); + } + .selected-filters-row { + display: flex; + flex-wrap: wrap; + gap: 6px; + margin-top: 6px; + } + .selected-filter { + display: inline-flex; + align-items: center; + gap: 6px; + padding: 2px 10px; + border-radius: 20px; + background: var(--sky-light); + border: 1px solid rgba(26, 115, 200, 0.3); + color: var(--sky); + font-family: "IBM Plex Sans", sans-serif; + font-size: 11px; + } + .selected-filter button { + border: none; + background: none; + color: var(--sky); + cursor: pointer; + font-size: 14px; + line-height: 1; + padding: 0; + } + + /* Tabs */ + .tabs-bar { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r); + padding: 4px; + display: flex; + gap: 0; + margin-bottom: 12px; + } + .tab { + flex: 1; + font-family: "Barlow Condensed", sans-serif; + font-size: 12px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + padding: 10px 12px; + border: none; + border-radius: var(--r); + background: transparent; + color: var(--text-3); + cursor: pointer; + } + .tab:hover { color: var(--text); background: var(--bg-alt); } + .tab.active { + background: var(--navy); + color: #fff; + } + .tab-content { + display: none; + animation: fadeup 0.22s ease forwards; + } + .tab-content.active { display: block; } + @keyframes fadeup { + from { opacity: 0; transform: translateY(5px); } + to { opacity: 1; transform: translateY(0); } + } + + /* Charts */ + .charts-row { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + margin-bottom: 12px; + } + .chart-panel { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + } + .chart-panel-head { + background: var(--bg); + border-bottom: 1px solid var(--rule); + padding: 8px 14px; + display: flex; + align-items: center; + gap: 10px; + } + .chart-panel-indicator { + width: 3px; + height: 14px; + background: var(--sky); + border-radius: 2px; + flex-shrink: 0; + } + .chart-panel-title { + font-family: "Barlow Condensed", sans-serif; + font-size: 12px; + font-weight: 700; + letter-spacing: 2px; + text-transform: uppercase; + color: var(--text-2); + } + .chart-panel-body { + height: 260px; + display: flex; + align-items: stretch; + padding: 8px 8px 8px 12px; + } + .chart-canvas-wrap { + flex: 1; + min-width: 0; + position: relative; + } + .chart-legend-wrap { + width: 190px; + flex-shrink: 0; + padding-left: 14px; + overflow-y: auto; + } + .legend-item { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 6px; + } + .legend-dot { + width: 9px; + height: 9px; + border-radius: 50%; + flex-shrink: 0; + } + .legend-lbl { + font-family: "IBM Plex Sans", sans-serif; + font-size: 11px; + color: var(--text-2); + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + } + .legend-val { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 500; + color: var(--text); + } + + .timeline-panel { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); + margin-bottom: 12px; + overflow: hidden; + } + .timeline-panel .chart-panel-body { height: auto; flex-direction: column; padding: 12px 14px 8px; } + .line-legend { + display: flex; + flex-wrap: wrap; + gap: 14px; + margin-bottom: 8px; + font-family: "IBM Plex Sans", sans-serif; + font-size: 11px; + color: var(--text-2); + } + .line-legend span { display: inline-flex; align-items: center; gap: 6px; } + .line-legend i { + display: inline-block; + width: 12px; + height: 3px; + border-radius: 1px; + } + .line-chart-wrap { height: 240px; position: relative; } + + .wt-head { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 2.5px; + color: var(--text-3); + display: flex; + align-items: center; + gap: 10px; + margin: 16px 0 8px; + padding: 0 4px; + } + .wt-head::before { + content: ""; + width: 16px; + height: 2px; + background: var(--sky); + flex-shrink: 0; + } + .weekly-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + .weekly-table th { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-3); + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--rule-faint); + background: var(--surface); + } + .weekly-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--rule-faint); + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + color: var(--text-2); + } + .weekly-table td.num { + font-family: "Barlow Condensed", sans-serif; + font-size: 14px; + font-weight: 700; + color: var(--text); + } + + /* Table tab */ + .table-panel { + background: #fff; + border: 1px solid var(--rule); + box-shadow: var(--shadow-sm); + border-radius: var(--r-lg); + overflow: hidden; + } + .table-scroll { overflow-x: auto; max-height: 70vh; } + .inc-table { + width: 100%; + border-collapse: collapse; + min-width: 960px; + } + .inc-table thead { + background: var(--navy); + position: sticky; + top: 0; + z-index: 2; + } + .inc-table th { + font-family: "Barlow Condensed", sans-serif; + font-size: 11px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1.8px; + color: rgba(255, 255, 255, 0.65); + text-align: left; + padding: 10px 13px; + border-right: 1px solid rgba(255, 255, 255, 0.06); + cursor: pointer; + user-select: none; + white-space: nowrap; + } + .inc-table th:hover { color: #fff; } + .inc-table th .sort-indicator { font-size: 9px; margin-left: 4px; opacity: 0.9; } + .inc-table tbody tr:nth-child(even) { background: rgba(238, 241, 246, 0.5); } + .inc-table tbody tr:hover { background: var(--sky-light); } + .inc-table td { + font-family: "IBM Plex Sans", sans-serif; + font-size: 12px; + padding: 8px 13px; + border-right: 1px solid var(--rule-faint); + vertical-align: top; + } + .inc-table td.mono { font-family: "IBM Plex Mono", monospace; font-size: 11px; } + .number-link { + font-family: "IBM Plex Mono", monospace; + font-size: 11px; + font-weight: 500; + color: var(--sky); + text-decoration: none; + } + .number-link:hover { color: var(--navy-mid); text-decoration: underline; } + td.overdue { color: var(--alert) !important; font-weight: 600; } + + /* Status badges */ + .status-badge { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 2px 8px; + border-radius: 20px; + font-family: "IBM Plex Sans", sans-serif; + font-size: 11px; + font-weight: 500; + } + .status-badge::before { + content: ""; + width: 5px; + height: 5px; + border-radius: 50%; + background: currentColor; + opacity: 0.8; + } + .status-badge.registered { background: #e3edf9; color: #1565c0; } + .status-badge.resolved { background: #e6f5ef; color: #0a7c4e; } + .status-badge.resumed { background: #fef3e2; color: #b45309; } + .status-badge.waiting { background: #f3e8fd; color: #7c3aed; } + .status-badge.closed { background: #e6f5ef; color: #065f46; } + .status-badge.chotvcom { background: #e0f2fe; color: #0369a1; } + .status-badge.inprogress { background: #fffbeb; color: #92400e; } + .status-badge.negotiation { background: #fdf2f8; color: #9d174d; } + .status-badge.negative { background: #fdecea; color: #c0392b; } + .status-badge.contractor { background: #e6faf6; color: #0d6b56; } + .status-badge.problem { background: #fff7ed; color: #c2410c; } + .status-badge.analiz { background: #f5f3ff; color: #5b21b6; } + .status-badge.chotvcom1 { background: #eff6ff; color: #1d4ed8; } + .status-badge.unknown { background: var(--bg-alt); color: var(--text-2); } + + /* Employees */ + .emp-panel { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + box-shadow: var(--shadow-sm); + overflow: hidden; + } + .emp-block { border-bottom: 1px solid var(--rule-faint); } + .emp-block:last-child { border-bottom: none; } + .emp-header { + display: flex; + align-items: center; + gap: 10px; + padding: 12px 14px; + cursor: pointer; + border-left: 3px solid transparent; + transition: background 0.15s ease, border-color 0.15s ease; + } + .emp-header:hover { background: var(--bg); } + .emp-header.expanded { + background: var(--sky-light); + border-left-color: var(--sky); + border-bottom: 1px solid var(--rule); + } + .emp-chevron { + font-size: 10px; + color: var(--text-3); + transition: transform 0.2s ease, color 0.2s ease; + width: 14px; + text-align: center; + } + .emp-header.expanded .emp-chevron { + transform: rotate(90deg); + color: var(--sky); + } + .emp-name { + font-family: "Barlow Condensed", sans-serif; + font-size: 15px; + font-weight: 600; + color: var(--text); + flex: 1; + min-width: 0; + } + .emp-header.expanded .emp-name { color: var(--navy); } + .emp-stats { + display: flex; + flex-wrap: wrap; + gap: 6px; + } + .emp-stat { + background: var(--bg); + border: 1px solid var(--rule); + border-radius: var(--r); + padding: 4px 12px; + text-align: center; + } + .emp-stat-val { + font-family: "Barlow Condensed", sans-serif; + font-size: 18px; + font-weight: 800; + line-height: 1.1; + } + .emp-stat-lbl { + font-family: "Barlow Condensed", sans-serif; + font-size: 8px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text-3); + margin-top: 2px; + } + .s-total { color: var(--steel); } + .s-closed { color: var(--go); } + .s-prog { color: var(--warn); } + .s-overdue { color: var(--alert); } + + .emp-table-wrap { display: none; padding: 0 14px 14px 38px; } + .emp-header.expanded + .emp-table-wrap { display: block; } + .emp-table { + width: 100%; + border-collapse: collapse; + font-size: 12px; + } + .emp-table thead { background: #f0f4fa; } + .emp-table th { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + letter-spacing: 1px; + color: var(--text-2); + text-align: left; + padding: 8px 10px; + border-bottom: 1px solid var(--rule-faint); + } + .emp-table td { + padding: 8px 10px; + border-bottom: 1px solid var(--rule-faint); + background: var(--bg); + font-family: "IBM Plex Sans", sans-serif; + } + .emp-table td.mono { font-family: "IBM Plex Mono", monospace; font-size: 11px; } + .emp-table tbody tr:hover td { background: var(--sky-light); } + + @media (max-width: 1100px) { + .charts-row { grid-template-columns: 1fr; } + .kpi-strip { grid-template-columns: repeat(2, 1fr); } + } + @media (max-width: 640px) { + .kpi-strip { grid-template-columns: 1fr; } + .topbar { flex-wrap: wrap; height: auto; } + .topbar-seg:last-child { margin-left: 0; } + } + + /* Страница «Источник данных» */ + .data-stats-row { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(220px, 1fr)); + gap: 10px; + margin-bottom: 14px; + } + .data-stat-card { + background: #fff; + border: 1px solid var(--rule); + border-radius: var(--r-lg); + padding: 14px 16px; + box-shadow: var(--shadow-sm); + border-left: 3px solid var(--sky); + } + .data-stat-card .ds-val { + font-family: "Barlow Condensed", sans-serif; + font-size: 28px; + font-weight: 800; + color: var(--steel); + line-height: 1; + } + .data-stat-card .ds-val.ds-warn { color: var(--warn); } + .data-stat-card .ds-lbl { + font-family: "Barlow Condensed", sans-serif; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 1.5px; + color: var(--text-3); + margin-top: 8px; + } + .data-stat-card .ds-sub { + font-family: "IBM Plex Mono", monospace; + font-size: 10px; + color: var(--text-3); + margin-top: 6px; + } + .data-info-panel { + background: var(--surface); + border: 1px solid var(--rule); + border-radius: var(--r); + padding: 12px 16px; + margin-top: 14px; + font-family: "IBM Plex Sans", sans-serif; + font-size: 12px; + color: var(--text-2); + line-height: 1.5; + } + .data-info-panel strong { + font-family: "Barlow Condensed", sans-serif; + font-size: 11px; + letter-spacing: 1px; + text-transform: uppercase; + color: var(--text-3); + display: block; + margin-bottom: 6px; + } diff --git a/data.html b/data.html new file mode 100644 index 0000000..adf5a62 --- /dev/null +++ b/data.html @@ -0,0 +1,210 @@ + + + + + + Источник данных — ServiceDesk Monitor + + + + + + + + +
+
+
+ +
+
ServiceDesk Monitor
+
OMC · Ситуационный центр
+
+
+ +
+ Статус + АКТИВЕН +
+
+ В базе + +
+
+ Дата системы + +
+
+ +
+ Подсистема + Источник данных + + CSV · JSON · Excel → PostgreSQL, ключ «Название» +
+ +
+
+
+
+
Объектов в базе
+
GET /api/incidents
+
+
+
+
Сохранено при последней загрузке
+
+
+
+
+
Пропущено (нет «Название»)
+
Строк в файле: —
+
+
+ +
+ Загрузка файла + + CSV, JSON или Excel — те же колонки, что в экспорте ServiceDesk + +
+ +
+
+
+ +
+ Справка + После разбора файл отправляется в API; для каждой строки ключ — целое число из поля «Название»: существующая запись обновляется, новая добавляется. + Откройте мониторинг для графиков и таблицы. +
+
+
+ + + + + diff --git a/index.html b/index.html index 0ffc6fd..176c2fc 100644 --- a/index.html +++ b/index.html @@ -8,927 +8,8 @@ - - -
@@ -940,6 +21,10 @@
OMC · Ситуационный центр
+
Статус АКТИВЕН @@ -965,17 +50,10 @@ Подсистема Мониторинг инцидентов - Файл: CSV · JSON · Excel · сохранение в PostgreSQL по ключу «Название» + Данные из PostgreSQL · загрузка файла: раздел «Источник данных»
-
- Источник данных - - Файл не выбран — CSV, JSON или Excel (.xlsx, .xls) с теми же колонками, что в экспорте ServiceDesk - -
-
@@ -2022,21 +1100,6 @@ applyFilters(); } - function syncRowsToServer(rows) { - return fetch("/api/incidents/sync", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ rows: rows }) - }).then(function (r) { - if (!r.ok) { - return r.text().then(function (t) { - throw new Error(t || "HTTP " + r.status); - }); - } - return r.json(); - }); - } - function tryLoadFromDb() { fetch("/api/incidents") .then(function (r) { @@ -2049,120 +1112,9 @@ el("success").classList.add("show"); el("success").textContent = "✓ Загружено из базы: " + j.rows.length + " записей"; }) - .catch(function () { /* API недоступен — работа только с файлом */ }); + .catch(function () { /* API недоступен */ }); } - function parseExcelToRows(buffer) { - if (typeof XLSX === "undefined") { - throw new Error("Библиотека Excel не загружена (XLSX). Проверьте сеть и CDN."); - } - const wb = XLSX.read(buffer, { type: "array" }); - if (!wb.SheetNames || !wb.SheetNames.length) { - throw new Error("В книге Excel нет листов"); - } - const ws = wb.Sheets[wb.SheetNames[0]]; - const rows = XLSX.utils.sheet_to_json(ws, { - defval: "", - raw: false, - blankrows: false - }); - return rows; - } - - function handleFile(file) { - if (!file) return; - el("upload-hint").textContent = file.name; - el("success").classList.remove("show"); - el("error").classList.remove("show"); - el("loading").classList.add("show"); - el("loading").textContent = "Загрузка и разбор файла…"; - - const lower = file.name.toLowerCase(); - const isExcel = lower.endsWith(".xlsx") || lower.endsWith(".xls"); - - function finish(rows) { - if (!rows.length) throw new Error("Нет строк данных"); - el("loading").textContent = "Сохранение в базу данных…"; - syncRowsToServer(rows) - .then(function (j) { - processData(rows); - el("loading").classList.remove("show"); - el("error").classList.remove("show"); - el("success").classList.add("show"); - var msg = "✓ В базе сохранено: " + j.applied + " из " + rows.length; - if (j.skipped) msg += " (без ключа «Название»: " + j.skipped + ")"; - el("success").textContent = msg; - }) - .catch(function (err) { - processData(rows); - el("loading").classList.remove("show"); - el("success").classList.remove("show"); - el("error").classList.add("show"); - el("error").textContent = - "✗ Данные отображены из файла. Ошибка БД: " + - (err && err.message ? err.message : String(err)); - }); - } - - function onFail(err) { - el("loading").classList.remove("show"); - el("error").classList.add("show"); - el("error").textContent = "✗ " + (err && err.message ? err.message : String(err)); - } - - if (isExcel) { - const reader = new FileReader(); - reader.onload = function (ev) { - try { - const buf = ev.target.result; - const rows = parseExcelToRows(new Uint8Array(buf)); - finish(rows); - } catch (err) { - onFail(err); - } - }; - reader.onerror = function () { - onFail(new Error("Не удалось прочитать файл")); - }; - reader.readAsArrayBuffer(file); - return; - } - - const reader = new FileReader(); - reader.onload = function (ev) { - try { - const text = String(ev.target.result || ""); - let rows; - if (lower.endsWith(".json")) { - const data = JSON.parse(text); - if (!Array.isArray(data)) throw new Error("JSON должен быть массивом объектов"); - rows = data; - } else { - const parsed = Papa.parse(text, { header: true, skipEmptyLines: true }); - if (parsed.errors && parsed.errors.length) { - console.warn(parsed.errors); - } - rows = parsed.data || []; - } - finish(rows); - } catch (err) { - onFail(err); - } - }; - reader.onerror = function () { - onFail(new Error("Не удалось прочитать файл")); - }; - reader.readAsText(file, "UTF-8"); - } - - el("btn-upload").addEventListener("click", function () { - el("file-input").click(); - }); - el("file-input").addEventListener("change", function () { - const f = el("file-input").files[0]; - if (f) handleFile(f); - }); - el("btn-today").addEventListener("click", function () { controlDate = startOfDay(new Date()); setControlInput(); diff --git a/js/upload-core.js b/js/upload-core.js new file mode 100644 index 0000000..a10c131 --- /dev/null +++ b/js/upload-core.js @@ -0,0 +1,94 @@ +/** + * Разбор файла (CSV / JSON / Excel) и синхронизация с API. + * Зависимости: глобальные Papa, XLSX (CDN на странице). + */ +(function (global) { + function parseExcelToRows(buffer) { + if (typeof XLSX === "undefined") { + throw new Error("Библиотека Excel не загружена (XLSX). Проверьте сеть и CDN."); + } + const wb = XLSX.read(buffer, { type: "array" }); + if (!wb.SheetNames || !wb.SheetNames.length) { + throw new Error("В книге Excel нет листов"); + } + const ws = wb.Sheets[wb.SheetNames[0]]; + return XLSX.utils.sheet_to_json(ws, { + defval: "", + raw: false, + blankrows: false + }); + } + + function parseFileToRows(file) { + return new Promise(function (resolve, reject) { + const lower = file.name.toLowerCase(); + const isExcel = lower.endsWith(".xlsx") || lower.endsWith(".xls"); + if (isExcel) { + const reader = new FileReader(); + reader.onload = function (ev) { + try { + resolve(parseExcelToRows(new Uint8Array(ev.target.result))); + } catch (e) { + reject(e); + } + }; + reader.onerror = function () { + reject(new Error("Не удалось прочитать файл")); + }; + reader.readAsArrayBuffer(file); + return; + } + const reader = new FileReader(); + reader.onload = function (ev) { + try { + const text = String(ev.target.result || ""); + let rows; + if (lower.endsWith(".json")) { + const data = JSON.parse(text); + if (!Array.isArray(data)) throw new Error("JSON должен быть массивом объектов"); + rows = data; + } else { + const parsed = Papa.parse(text, { header: true, skipEmptyLines: true }); + if (parsed.errors && parsed.errors.length) console.warn(parsed.errors); + rows = parsed.data || []; + } + resolve(rows); + } catch (e) { + reject(e); + } + }; + reader.onerror = function () { + reject(new Error("Не удалось прочитать файл")); + }; + reader.readAsText(file, "UTF-8"); + }); + } + + function syncRowsToServer(rows) { + return fetch("/api/incidents/sync", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ rows: rows }) + }).then(function (r) { + if (!r.ok) { + return r.text().then(function (t) { + throw new Error(t || "HTTP " + r.status); + }); + } + return r.json(); + }); + } + + function fetchIncidents() { + return fetch("/api/incidents").then(function (r) { + if (!r.ok) return null; + return r.json(); + }); + } + + global.OmcUpload = { + parseFileToRows: parseFileToRows, + syncRowsToServer: syncRowsToServer, + fetchIncidents: fetchIncidents + }; +})(typeof window !== "undefined" ? window : this);