intra_monitor/public/index_old.html
2026-03-18 14:19:29 +03:00

1610 lines
79 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Монитор заявок — CleverParking</title>
<style>
@import url('https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&family=Unbounded:wght@700;900&display=swap');
:root {
--bg: #0b0e16; --surface: #12151f; --surface2: #181c28; --border: #222638;
--accent: #4fffb0; --accent-dim: rgba(79,255,176,0.1); --accent-border: rgba(79,255,176,0.25);
--warn: #ff5f57; --warn-dim: rgba(255,95,87,0.1); --warn-border: rgba(255,95,87,0.3);
--muted: #4a5270; --text: #d8e0f5; --text-dim: #7880a0;
}
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: var(--bg); color: var(--text); font-family: 'JetBrains Mono', monospace; min-height: 100vh; }
body::before {
content: ''; position: fixed; inset: 0;
background-image: linear-gradient(rgba(79,255,176,0.025) 1px, transparent 1px), linear-gradient(90deg, rgba(79,255,176,0.025) 1px, transparent 1px);
background-size: 48px 48px; pointer-events: none; z-index: 0;
}
.shell { position: relative; z-index: 1; max-width: 1300px; margin: 0 auto; padding: 32px 24px 64px; }
/* LOGIN OVERLAY */
.login-overlay {
position: fixed; inset: 0; background: var(--bg); z-index: 200;
display: flex; align-items: center; justify-content: center;
}
.login-overlay.hidden { display: none; }
.login-box {
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
padding: 36px 40px; width: 360px;
}
.login-title { font-family: 'Unbounded', sans-serif; font-size: 18px; color: var(--accent); margin-bottom: 6px; }
.login-sub { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-bottom: 28px; }
.login-field { display: flex; flex-direction: column; gap: 6px; margin-bottom: 16px; }
.login-field label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.login-field input {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 13px;
padding: 11px 14px; outline: none; transition: border-color 0.2s;
}
.login-field input:focus { border-color: var(--accent); }
.login-btn {
width: 100%; margin-top: 8px; background: var(--accent); border: none; border-radius: 8px;
color: #0b0e16; font-family: 'JetBrains Mono', monospace; font-size: 13px; font-weight: 700;
padding: 12px; cursor: pointer; transition: opacity 0.2s;
}
.login-btn:hover { opacity: 0.88; }
.login-error { font-size: 11px; color: var(--warn); margin-top: 10px; text-align: center; min-height: 18px; }
/* UPDATE BANNER */
.update-banner {
display: none; align-items: center; justify-content: space-between; gap: 12px;
background: rgba(79,255,176,0.07); border: 1px solid var(--accent-border);
border-radius: 10px; padding: 10px 16px; margin-bottom: 12px;
font-size: 11px; color: var(--accent);
}
.update-banner.visible { display: flex; }
.update-banner button {
background: var(--accent); border: none; border-radius: 6px; color: #0b0e16;
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
padding: 6px 14px; cursor: pointer; white-space: nowrap;
}
/* HEADER */
header { display: flex; align-items: flex-end; justify-content: space-between; margin-bottom: 24px; flex-wrap: wrap; gap: 16px; }
.brand .title { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 900; color: var(--accent); letter-spacing: -0.5px; }
.brand .subtitle { font-size: 10px; color: var(--muted); margin-top: 5px; letter-spacing: 0.12em; text-transform: uppercase; }
.header-meta { text-align: right; font-size: 11px; color: var(--text-dim); line-height: 1.8; }
.header-meta span { color: var(--accent); }
.user-badge {
display: inline-flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim);
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
padding: 6px 12px; cursor: pointer; transition: border-color 0.15s;
}
.user-badge:hover { border-color: var(--warn); color: var(--warn); }
/* SETTINGS */
.settings-toggle {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 16px; margin-bottom: 12px; cursor: pointer;
display: flex; align-items: center; gap: 8px; font-size: 11px; color: var(--text-dim);
transition: all 0.15s; user-select: none;
}
.settings-toggle:hover { border-color: var(--accent); color: var(--accent); }
.settings-toggle .arrow { transition: transform 0.2s; }
.settings-toggle.open .arrow { transform: rotate(90deg); }
.settings-panel { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; padding: 22px 24px; margin-bottom: 16px; display: none; }
.settings-panel.open { display: block; }
.settings-section-title { font-size: 10px; text-transform: uppercase; letter-spacing: 0.12em; color: var(--muted); margin-bottom: 14px; }
.config-row { display: flex; gap: 12px; flex-wrap: wrap; align-items: flex-end; margin-bottom: 20px; }
.config-field { display: flex; flex-direction: column; gap: 6px; flex: 1; min-width: 180px; }
.config-field label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.config-field input {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px;
padding: 10px 13px; outline: none; transition: border-color 0.2s; width: 100%;
}
.config-field input:focus { border-color: var(--accent); }
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
/* BUTTONS */
.btn {
background: var(--accent); border: none; border-radius: 8px; color: #0b0e16;
cursor: pointer; font-family: 'JetBrains Mono', monospace; font-size: 12px; font-weight: 600;
padding: 10px 20px; transition: opacity 0.2s, transform 0.1s; white-space: nowrap;
}
.btn:hover { opacity: 0.88; } .btn:active { transform: scale(0.98); } .btn:disabled { opacity: 0.4; cursor: not-allowed; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text-dim); }
.btn-outline:hover { border-color: var(--accent); color: var(--accent); opacity: 1; }
/* OPTIONS ROW */
.options-row {
display: flex; gap: 20px; flex-wrap: wrap; align-items: center; margin-bottom: 20px;
padding: 14px 18px; background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
}
.checkbox-label {
display: flex; align-items: center; gap: 8px; font-size: 12px;
color: var(--text-dim); cursor: pointer; user-select: none;
}
.checkbox-label input[type=checkbox] {
appearance: none; width: 16px; height: 16px; border: 1px solid var(--border);
border-radius: 4px; background: var(--surface2); cursor: pointer; flex-shrink: 0;
transition: all 0.15s; position: relative;
}
.checkbox-label input[type=checkbox]:checked { background: var(--accent); border-color: var(--accent); }
.checkbox-label input[type=checkbox]:checked::after {
content: '✓'; position: absolute; top: -1px; left: 2px; font-size: 11px; color: #0b0e16; font-weight: 700;
}
.checkbox-label:hover { color: var(--text); }
.select-field { display: flex; align-items: center; gap: 8px; font-size: 12px; color: var(--text-dim); margin-left: auto; }
.select-field select {
background: var(--surface2); border: 1px solid var(--border); border-radius: 6px;
color: var(--text); font-family: 'JetBrains Mono', monospace; font-size: 12px;
padding: 6px 10px; outline: none; cursor: pointer; transition: border-color 0.15s;
}
.select-field select:focus { border-color: var(--accent); }
/* STATS */
.stats-bar { display: flex; gap: 12px; margin-bottom: 20px; flex-wrap: wrap; }
.stat-pill { background: var(--surface); border: 1px solid var(--border); border-radius: 10px; padding: 12px 18px; flex: 1; min-width: 120px; text-align: center; }
.stat-pill .val { font-family: 'Unbounded', sans-serif; font-size: 24px; font-weight: 900; line-height: 1; margin-bottom: 4px; }
.stat-pill .lbl { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); }
.stat-pill.warn .val { color: var(--warn); }
.stat-pill.ok .val { color: var(--accent); }
.stat-pill.neutral .val { color: var(--text); }
/* FILTERS */
.filter-row { display: flex; gap: 8px; margin-bottom: 16px; flex-wrap: wrap; align-items: center; }
.filter-tab {
background: var(--surface); border: 1px solid var(--border); border-radius: 8px;
color: var(--text-dim); cursor: pointer; font-family: 'JetBrains Mono', monospace;
font-size: 11px; padding: 7px 14px; transition: all 0.15s;
}
.filter-tab:hover { border-color: var(--accent); color: var(--accent); }
.filter-tab.active { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent); }
.filter-sep { width: 1px; height: 24px; background: var(--border); margin: 0 4px; }
.filter-search {
margin-left: auto; background: var(--surface); border: 1px solid var(--border);
border-radius: 8px; color: var(--text); font-family: 'JetBrains Mono', monospace;
font-size: 12px; padding: 7px 13px; outline: none; transition: border-color 0.2s; width: 220px;
}
.filter-search:focus { border-color: var(--accent); }
.filter-search::placeholder { color: var(--muted); }
/* TAG FILTER */
.tag-filter-wrap {
display: none; gap: 8px; align-items: center; flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 16px; margin-bottom: 16px; font-size: 11px;
}
.tag-filter-wrap.visible { display: flex; }
.tag-filter-wrap label { color: var(--muted); white-space: nowrap; }
.tag-chip {
display: inline-flex; align-items: center; gap: 5px;
background: var(--surface2); border: 1px solid var(--border); border-radius: 20px;
padding: 4px 10px; font-size: 11px; color: var(--text-dim); cursor: pointer; transition: all 0.15s; user-select: none;
}
.tag-chip:hover { border-color: var(--accent); color: var(--accent); }
.tag-chip.active { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent); }
.tag-chip .tag-dot { width: 5px; height: 5px; border-radius: 50%; background: currentColor; flex-shrink: 0; }
.tag-clear { font-size: 10px; color: var(--muted); cursor: pointer; padding: 4px 8px; border-radius: 6px; transition: color 0.15s; }
.tag-clear:hover { color: var(--warn); }
/* STATE CHIPS */
.state-filter-row { display: none; gap: 8px; align-items: center; flex-wrap: wrap; margin-bottom: 12px; }
.state-filter-row.visible { display: flex; }
.state-filter-label { font-size: 10px; color: var(--muted); text-transform: uppercase; letter-spacing: 0.1em; margin-right: 2px; }
.state-chip {
display: inline-flex; align-items: center; gap: 6px;
padding: 6px 12px; border-radius: 20px; font-size: 11px; font-weight: 500;
cursor: pointer; user-select: none; transition: all 0.15s;
border: 1px solid var(--border); background: var(--surface); color: var(--muted);
}
.state-chip:hover { border-color: var(--text-dim); color: var(--text); }
.state-chip .chip-check { font-size: 10px; opacity: 0; transition: opacity 0.15s; }
.state-chip.on .chip-check { opacity: 1; }
.state-chip.chip-hidden.on { background: rgba(255,95,87,0.08); border-color: var(--warn-border); color: var(--warn); }
.state-chip.chip-resolved.on { background: rgba(79,255,176,0.10); border-color: var(--accent-border); color: var(--accent); }
.state-chip.chip-reply.on { background: rgba(255,165,0,0.10); border-color: rgba(255,165,0,0.35); color: #ffb347; }
.state-chip.chip-vyezdnye.on { background: rgba(100,160,255,0.10); border-color: rgba(100,160,255,0.35); color: #64a0ff; }
.state-chip.chip-all.on { background: var(--accent-dim); border-color: var(--accent-border); color: var(--accent); }
.state-chip.chip-unmarked.on { background: rgba(255,255,255,0.04); border-color: #444; color: var(--text-dim); }
/* TABLE */
.table-wrap { background: var(--surface); border: 1px solid var(--border); border-radius: 14px; overflow: hidden; }
table { width: 100%; border-collapse: collapse; }
thead th {
background: var(--surface2); padding: 12px 16px; text-align: left;
font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em;
color: var(--muted); border-bottom: 1px solid var(--border); font-weight: 500;
}
tbody tr { border-bottom: 1px solid var(--border); transition: background 0.15s; cursor: pointer; }
tbody tr:last-child { border-bottom: none; }
tbody tr:hover { background: var(--surface2); }
tbody tr.unread td:first-child { border-left: 3px solid var(--muted); padding-left: 13px; }
.unread-dot { display:inline-block; width:7px; height:7px; border-radius:50%; margin-left:6px; vertical-align:middle; flex-shrink:0; }
tbody td { padding: 12px 16px; font-size: 12px; vertical-align: middle; }
.task-link { color: var(--text); text-decoration: none; font-weight: 600; transition: color 0.15s; }
.task-link:hover { color: var(--accent); }
.task-num { font-family: 'Unbounded', sans-serif; font-size: 13px; }
.task-name { color: var(--text-dim); font-size: 11px; margin-top: 3px; max-width: 240px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.author-chip {
display: inline-flex; align-items: center; gap: 6px; padding: 4px 10px; border-radius: 20px;
font-size: 11px; font-weight: 500; max-width: 180px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.author-chip.external { background: var(--warn-dim); border: 1px solid var(--warn-border); color: var(--warn); }
.author-chip.internal { background: var(--accent-dim); border: 1px solid var(--accent-border); color: var(--accent); }
.author-chip.unknown { background: rgba(255,255,255,0.04); border: 1px solid var(--border); color: var(--muted); }
.dot { width: 6px; height: 6px; border-radius: 50%; flex-shrink: 0; }
.dot.external { background: var(--warn); }
.dot.internal { background: var(--accent); }
.dot.unknown { background: var(--muted); }
.time-cell { color: var(--text-dim); font-size: 11px; white-space: nowrap; }
.time-ago { font-size: 10px; margin-top: 2px; }
.time-ago.old { color: var(--warn); } .time-ago.fresh { color: var(--text-dim); }
.comment-preview { color: var(--text-dim); font-size: 11px; font-style: italic; max-width: 200px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
/* ROW STATE STYLES */
tbody tr.resolved { background: rgba(79,255,176,0.06) !important; }
tbody tr.resolved:hover { background: rgba(79,255,176,0.11) !important; }
tbody tr.resolved td:first-child { border-left: 3px solid var(--accent); padding-left: 13px; }
tbody tr.needs-reply { background: rgba(255,95,87,0.06) !important; }
tbody tr.needs-reply:hover { background: rgba(255,95,87,0.10) !important; }
tbody tr.needs-reply td:first-child { border-left: 3px solid var(--warn); padding-left: 13px; }
tbody tr.row-vyezdnye { background: rgba(100,160,255,0.06) !important; }
tbody tr.row-vyezdnye:hover { background: rgba(100,160,255,0.11) !important; }
tbody tr.row-vyezdnye td:first-child { border-left: 3px solid #64a0ff; padding-left: 13px; }
tbody tr.row-hidden { opacity: 0.38; }
tbody tr.row-hidden td:first-child { border-left: 3px solid #444; padding-left: 13px; }
/* ACTION BUTTONS */
.row-actions { display: flex; gap: 5px; margin-top: 6px; flex-wrap: wrap; }
.act-btn {
background: var(--surface2); border: 1px solid var(--border); border-radius: 5px;
color: var(--text-dim); font-family: 'JetBrains Mono', monospace; font-size: 10px;
padding: 3px 8px; cursor: pointer; transition: all 0.15s; white-space: nowrap; line-height: 1.4;
}
.act-btn:hover { border-color: var(--accent); color: var(--accent); }
.act-btn.active-resolve { background: rgba(79,255,176,0.12); border-color: var(--accent-border); color: var(--accent); }
.act-btn.active-needs-reply { background: rgba(255,95,87,0.12); border-color: var(--warn-border); color: var(--warn); }
.act-btn.active-vyezdnye { background: rgba(100,160,255,0.12); border-color: rgba(100,160,255,0.4); color: #64a0ff; }
.act-btn.active-hide { background: rgba(255,95,87,0.08); border-color: var(--warn-border); color: var(--warn); }
.act-btn.loading { opacity: 0.5; cursor: not-allowed; }
/* NOTE COLUMN */
.note-col { min-width: 180px; max-width: 260px; }
.note-col-inner { display: flex; flex-direction: column; gap: 6px; }
.note-col-text { font-size: 11px; color: #6be8aa; font-style: italic; line-height: 1.4; word-break: break-word; cursor: pointer; }
.note-col-text:hover { text-decoration: underline; }
.note-col-empty { font-size: 11px; color: var(--muted); cursor: pointer; user-select: none; }
.note-col-empty:hover { color: var(--accent); }
.note-meta { font-size: 9px; color: var(--muted); margin-top: 2px; }
/* NOTE EDITOR */
.note-editor-row td { padding: 0 !important; border-bottom: 1px solid var(--border); }
.note-editor-wrap { padding: 10px 16px; background: #0f1320; display: flex; gap: 8px; align-items: flex-end; }
.note-editor-wrap textarea {
flex: 1; background: var(--surface2); border: 1px solid var(--accent-border);
border-radius: 8px; color: var(--text); font-family: 'JetBrains Mono', monospace;
font-size: 12px; padding: 8px 12px; outline: none; resize: vertical; min-height: 60px; line-height: 1.5;
}
.note-editor-wrap textarea:focus { border-color: var(--accent); }
.note-save-btn {
background: var(--accent); border: none; border-radius: 7px; color: #0b0e16;
font-family: 'JetBrains Mono', monospace; font-size: 11px; font-weight: 700;
padding: 8px 14px; cursor: pointer; white-space: nowrap;
}
.note-cancel-btn {
background: transparent; border: 1px solid var(--border); border-radius: 7px;
color: var(--text-dim); font-family: 'JetBrains Mono', monospace; font-size: 11px;
padding: 8px 12px; cursor: pointer;
}
.note-cancel-btn:hover { border-color: var(--warn); color: var(--warn); }
/* EXPORT BAR */
.export-bar {
display: none; gap: 10px; align-items: center; flex-wrap: wrap;
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 16px; margin-bottom: 16px; font-size: 11px; color: var(--text-dim);
}
.export-bar.visible { display: flex; }
.export-bar strong { color: var(--accent); }
.btn-export {
background: transparent; border: 1px solid var(--accent-border); border-radius: 7px;
color: var(--accent); cursor: pointer; font-family: 'JetBrains Mono', monospace;
font-size: 11px; font-weight: 600; padding: 7px 14px; transition: all 0.15s; white-space: nowrap;
}
.btn-export:hover { background: var(--accent-dim); }
/* STATE BOX / SPINNER */
.state-box { text-align: center; padding: 64px 24px; color: var(--muted); }
.state-box .icon { font-size: 40px; margin-bottom: 16px; display: block; }
.state-box .msg { font-size: 14px; margin-bottom: 8px; color: var(--text-dim); }
.state-box .sub { font-size: 11px; color: var(--muted); }
.spinner {
display: inline-block; width: 32px; height: 32px;
border: 2px solid var(--border); border-top-color: var(--accent);
border-radius: 50%; animation: spin 0.7s linear infinite; margin-bottom: 16px;
}
@keyframes spin { to { transform: rotate(360deg); } }
/* MODAL */
.modal-overlay {
display: none; position: fixed; inset: 0; background: rgba(11,14,22,0.88);
backdrop-filter: blur(5px); z-index: 100; align-items: center; justify-content: center; padding: 24px;
}
.modal-overlay.open { display: flex; }
.modal {
background: var(--surface); border: 1px solid var(--border); border-radius: 16px;
max-width: 680px; width: 100%; max-height: 85vh; overflow: hidden;
display: flex; flex-direction: column; animation: modalIn 0.2s ease;
}
@keyframes modalIn { from { opacity:0; transform: translateY(14px); } to { opacity:1; transform: translateY(0); } }
.modal-header { padding: 24px 28px 0; flex-shrink: 0; }
.modal-header-top { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; margin-bottom: 16px; }
.modal-close { background: none; border: none; color: var(--muted); font-size: 22px; cursor: pointer; line-height: 1; transition: color 0.15s; flex-shrink: 0; }
.modal-close:hover { color: var(--text); }
.modal-task-num { font-family: 'Unbounded', sans-serif; font-size: 20px; font-weight: 900; color: var(--accent); }
.modal-task-name { color: var(--text-dim); font-size: 12px; margin-top: 3px; }
.modal-tabs { display: flex; gap: 0; border-bottom: 1px solid var(--border); }
.modal-tab {
padding: 10px 20px; font-family: 'JetBrains Mono', monospace; font-size: 11px;
color: var(--text-dim); cursor: pointer; border-bottom: 2px solid transparent; margin-bottom: -1px; transition: all 0.15s;
}
.modal-tab:hover { color: var(--text); }
.modal-tab.active { color: var(--accent); border-bottom-color: var(--accent); }
.modal-body { padding: 20px 28px 24px; overflow-y: auto; flex: 1; }
.modal-meta { display: flex; flex-direction: column; gap: 10px; margin-bottom: 18px; }
.modal-meta-row { display: flex; gap: 8px; align-items: center; font-size: 12px; }
.modal-meta-label { color: var(--muted); min-width: 90px; }
.modal-meta-value { color: var(--text); }
.modal-divider { border: none; border-top: 1px solid var(--border); margin: 14px 0; }
.modal-section-label { font-size: 10px; text-transform: uppercase; letter-spacing: 0.1em; color: var(--muted); margin-bottom: 10px; }
.modal-comment-text { color: var(--text); font-size: 13px; line-height: 1.7; white-space: pre-wrap; word-break: break-word; }
.modal-no-comment { color: var(--muted); font-style: italic; font-size: 12px; }
.modal-open-link {
display: inline-flex; align-items: center; gap: 6px; margin-top: 4px;
color: var(--accent); font-size: 11px; text-decoration: none;
border: 1px solid var(--accent-border); border-radius: 8px; padding: 7px 14px; transition: background 0.15s;
}
.modal-open-link:hover { background: var(--accent-dim); }
/* HISTORY */
.history-loading { text-align: center; padding: 32px; color: var(--muted); font-size: 12px; }
.history-loading .spinner { width: 24px; height: 24px; margin-bottom: 12px; }
.history-entry { margin-bottom: 20px; }
.history-entry-header { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.history-entry-author { font-size: 12px; font-weight: 600; }
.history-entry-author.is-client { color: var(--warn); }
.history-entry-author.is-staff { color: var(--accent); }
.history-entry-time { font-size: 10px; color: var(--muted); margin-left: auto; }
.history-entry-text {
background: var(--surface2); border: 1px solid var(--border); border-radius: 8px;
padding: 10px 14px; font-size: 12px; line-height: 1.6; color: var(--text);
white-space: pre-wrap; word-break: break-word;
}
.history-sep { border: none; border-top: 1px dashed var(--border); margin: 16px 0; }
.history-event { margin-top: 8px; }
.history-event:first-child { margin-top: 0; }
.history-event-type {
display: inline-block; font-size: 10px; text-transform: uppercase; letter-spacing: 0.08em;
color: var(--muted); background: var(--surface2); border: 1px solid var(--border);
border-radius: 4px; padding: 2px 7px; margin-bottom: 6px;
}
.history-event-type.is-comment { color: var(--accent); background: var(--accent-dim); border-color: var(--accent-border); }
.history-event-pill { font-size: 12px; color: var(--text-dim); padding: 4px 0; }
.intradesk-quote-block {
border-left: 3px solid var(--border); margin: 6px 0; padding: 6px 10px;
color: var(--text-dim); font-size: 11px; font-style: italic;
background: var(--surface2); border-radius: 0 4px 4px 0;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
/* TOAST */
.toast-wrap { position: fixed; bottom: 24px; right: 24px; z-index: 300; display: flex; flex-direction: column; gap: 8px; pointer-events: none; }
.toast {
background: var(--surface); border: 1px solid var(--border); border-radius: 10px;
padding: 10px 16px; font-size: 11px; color: var(--text); max-width: 320px;
animation: toastIn 0.2s ease; box-shadow: 0 4px 20px rgba(0,0,0,0.4);
display: flex; align-items: center; gap: 8px;
}
.toast.mark { border-color: var(--accent-border); }
.toast.note { border-color: rgba(107,232,170,0.3); }
.toast.reload { border-color: var(--accent-border); background: rgba(79,255,176,0.08); }
@keyframes toastIn { from { opacity:0; transform: translateY(8px); } to { opacity:1; transform: translateY(0); } }
@media (max-width: 768px) {
.config-row { flex-direction: column; }
.filter-search { width: 100%; margin-left: 0; }
.stats-bar { flex-direction: column; }
th.hide-m, td.hide-m { display: none; }
}
</style>
</head>
<body>
<div class="toast-wrap" id="toast-wrap"></div>
<!-- LOGIN OVERLAY -->
<div class="login-overlay" id="login-overlay">
<div class="login-box">
<div class="login-title">// ЗАЯВКИ</div>
<div class="login-sub">CleverParking · Монитор техподдержки</div>
<div class="login-field">
<label>Логин</label>
<input type="text" id="login-username" placeholder="username" autocomplete="username">
</div>
<div class="login-field">
<label>Пароль</label>
<input type="password" id="login-password" placeholder="••••••" autocomplete="current-password">
</div>
<button class="login-btn" onclick="doLogin()">Войти</button>
<div class="login-error" id="login-error"></div>
</div>
</div>
<!-- CONFIRM MODAL -->
<div class="modal-overlay" id="confirm-modal" onclick="if(event.target===this)this.classList.remove('open')">
<div class="modal" style="max-width:360px;">
<div class="modal-header" style="padding:24px 28px;">
<div class="modal-header-top" style="margin-bottom:0;">
<div>
<div class="modal-task-num" style="font-size:16px;">Отметить всё прочитанным?</div>
<div class="modal-task-name" style="margin-top:6px;">Будет отмечено <span id="confirm-count">0</span> заявок</div>
</div>
<button class="modal-close" onclick="document.getElementById('confirm-modal').classList.remove('open')">×</button>
</div>
</div>
<div style="padding:0 28px 24px;display:flex;gap:10px;">
<button class="btn" style="flex:1;" onclick="markAllRead();document.getElementById('confirm-modal').classList.remove('open')">✓ Да, прочитать всё</button>
<button class="btn btn-outline" style="flex:1;" onclick="document.getElementById('confirm-modal').classList.remove('open')">Отмена</button>
</div>
</div>
</div>
<!-- TASK MODAL -->
<div class="modal-overlay" id="modal" onclick="closeModalOnOverlay(event)">
<div class="modal">
<div class="modal-header">
<div class="modal-header-top">
<div>
<div class="modal-task-num" id="m-num"></div>
<div class="modal-task-name" id="m-name"></div>
</div>
<div style="display:flex;gap:8px;align-items:center;">
<a class="modal-open-link" id="m-link" href="#" target="_blank">↗ Открыть</a>
<button class="modal-close" onclick="closeModal()">×</button>
</div>
</div>
<div class="modal-tabs">
<div class="modal-tab active" id="tab-last" onclick="switchTab('last')">Последний комментарий</div>
<div class="modal-tab" id="tab-history" onclick="switchTab('history')">Вся история</div>
</div>
</div>
<div class="modal-body" id="modal-body"></div>
</div>
</div>
<div class="shell" id="app-shell" style="display:none;">
<header>
<div class="brand">
<div class="title">// ЗАЯВКИ</div>
<div class="subtitle">CleverParking · Монитор техподдержки</div>
</div>
<div style="display:flex;align-items:center;gap:12px;">
<div class="header-meta">
Обновлено: <span id="last-update"></span><br>
Загружено: <span id="total-loaded"></span> заявок
</div>
<div class="user-badge" onclick="doLogout()" title="Выйти">
👤 <span id="user-label"></span> · выйти
</div>
</div>
</header>
<!-- UPDATE BANNER -->
<div class="update-banner" id="update-banner">
<span>🔄 На сервере появились новые данные</span>
<button onclick="reloadData()">Обновить</button>
</div>
<!-- SETTINGS TOGGLE -->
<div class="settings-toggle" id="settings-toggle" onclick="toggleSettings()">
<span class="arrow"></span>
<span>⚙ Настройки подключения и автообновления</span>
</div>
<div class="settings-panel" id="settings-panel">
<div class="settings-section-title">Подключение к IntraDesk API</div>
<div class="config-row">
<div class="config-field" style="flex:2;min-width:260px;">
<label>API Key <span id="apikey-status" style="color:var(--accent);"></span></label>
<input type="password" id="api-key" placeholder="Введите новый ключ чтобы обновить">
</div>
<div class="config-field">
<label>Статусы (ID через запятую)</label>
<input type="text" id="status-ids" placeholder="68051,68046,...">
</div>
<div class="config-field">
<label>Фильтр событий (пусто = все)</label>
<input type="text" id="event-types" placeholder="50,55,10,30 или пусто">
</div>
</div>
<hr class="divider">
<div class="settings-section-title">Автообновление на сервере</div>
<div class="config-row">
<div class="config-field" style="min-width:200px;max-width:300px;">
<label>Интервал (секунды, 0 = выкл)</label>
<input type="number" id="server-refresh-interval" placeholder="300" min="0">
</div>
<div style="display:flex;align-items:flex-end;gap:8px;padding-bottom:1px;">
<button class="btn" onclick="saveSettings()">💾 Сохранить настройки</button>
</div>
</div>
<hr class="divider">
<div class="settings-section-title">Управление пользователями</div>
<div class="config-row">
<div class="config-field">
<label>Новый пользователь — логин</label>
<input type="text" id="new-user-login" placeholder="username">
</div>
<div class="config-field">
<label>Пароль (мин. 6 символов)</label>
<input type="password" id="new-user-pass" placeholder="••••••">
</div>
<div class="config-field">
<label>Admin Secret (из .env)</label>
<input type="password" id="new-user-secret" placeholder="••••••">
</div>
<div style="display:flex;align-items:flex-end;gap:8px;padding-bottom:1px;">
<button class="btn btn-outline" onclick="createUser()">+ Создать</button>
</div>
</div>
<div id="user-msg" style="font-size:11px;color:var(--accent);min-height:16px;"></div>
</div>
<!-- LOAD OPTIONS -->
<div class="options-row">
<button class="btn" id="load-btn" onclick="loadTasks()">▶ Загрузить / обновить заявки</button>
<label class="checkbox-label">
<input type="checkbox" id="opt-skip-empty" checked onchange="renderTable()">
Скрывать без комментариев
</label>
<div class="select-field">
<select id="date-filter" onchange="renderTable()">
<option value="0">Все даты</option>
<option value="today">Сегодня</option>
<option value="yesterday">Вчера</option>
<option value="3">3 дня</option>
<option value="7">Неделя</option>
<option value="14">2 недели</option>
<option value="30">Месяц</option>
</select>
</div>
<div class="select-field">
<span>Показывать</span>
<select id="opt-limit" onchange="renderTable()">
<option value="50">50</option>
<option value="100" selected>100</option>
<option value="200">200</option>
<option value="500">500</option>
<option value="0">Все</option>
</select>
<span>заявок</span>
</div>
<div id="refresh-countdown" style="font-size:10px;color:var(--muted);white-space:nowrap;"></div>
</div>
<!-- EXPORT BAR -->
<div class="export-bar visible" id="export-bar">
<strong>↓ Экспорт</strong>
<button class="btn-export" onclick="exportCSV()">⬇ CSV</button>
<button class="btn-export" onclick="exportJSON()">⬇ JSON</button>
<button class="btn-export" onclick="exportTXT()">⬇ TXT</button>
</div>
<!-- STATS -->
<div class="stats-bar" id="stats-bar" style="display:none;">
<div class="stat-pill warn"> <div class="val" id="stat-external">0</div> <div class="lbl">Не наши / клиент</div></div>
<div class="stat-pill ok"> <div class="val" id="stat-internal">0</div> <div class="lbl">Ответили мы</div></div>
<div class="stat-pill neutral"><div class="val" id="stat-nocomment">0</div> <div class="lbl">Без комментариев</div></div>
<div class="stat-pill neutral"><div class="val" id="stat-total">0</div> <div class="lbl">Всего</div></div>
<div class="stat-pill warn" style="cursor:pointer;" onclick="promptMarkAllRead()" title="Нажмите чтобы прочитать всё">
<div class="val" id="stat-unread">0</div><div class="lbl">Не просмотрено ↗</div>
</div>
</div>
<!-- FILTERS -->
<div class="filter-row" id="filter-row" style="display:none;">
<button class="filter-tab active" onclick="setFilter('all',this)">Все</button>
<div class="filter-sep"></div>
<button class="filter-tab" onclick="setFilter('external',this)">🔴 Не наши / клиент</button>
<button class="filter-tab" onclick="setFilter('internal',this)">🟢 Наши</button>
<button class="filter-tab" onclick="setFilter('nocomment',this)">⚪ Без комментариев</button>
<input class="filter-search" type="text" placeholder="Поиск..." id="search-input" oninput="renderTable()">
</div>
<!-- TAG FILTER -->
<div class="tag-filter-wrap" id="tag-filter-wrap">
<label>🏷 Теги:</label>
<span id="tag-chips"></span>
<span class="tag-clear" id="tag-clear" onclick="clearTagFilter()" style="display:none;">✕ Сбросить</span>
</div>
<!-- STATE CHIPS -->
<div class="state-filter-row" id="state-filter-row">
<span class="state-filter-label">Мои пометки:</span>
<div class="state-chip chip-all on" id="chip-all" onclick="toggleAllChip()" title="Показать все">
<span class="chip-check"></span> Все
</div>
<div class="filter-sep" style="height:20px;"></div>
<div class="state-chip chip-reply on" id="chip-reply" onclick="toggleStateChip('reply')" title="Нужно ответить">
<span class="chip-check"></span><span class="chip-icon">🔴</span> Нужно ответить
</div>
<div class="state-chip chip-vyezdnye on" id="chip-vyezdnye" onclick="toggleStateChip('vyezdnye')" title="Выездные">
<span class="chip-check"></span><span class="chip-icon">🚗</span> Выездные
</div>
<div class="state-chip chip-resolved on" id="chip-resolved" onclick="toggleStateChip('resolved')" title="Решено">
<span class="chip-check"></span><span class="chip-icon"></span> Решено
</div>
<div class="state-chip chip-hidden on" id="chip-hidden" onclick="toggleStateChip('hidden')" title="Скрытые">
<span class="chip-check"></span><span class="chip-icon">👁</span> Скрытые
</div>
<div class="state-chip chip-unmarked on" id="chip-unmarked" onclick="toggleStateChip('unmarked')" title="Без пометок">
<span class="chip-check"></span><span class="chip-icon"></span> Без пометок
</div>
</div>
<!-- TABLE -->
<div class="table-wrap" id="table-wrap">
<div class="state-box">
<span class="icon">📋</span>
<div class="msg">Нажмите «Загрузить / обновить заявки»</div>
<div class="sub">Или дождитесь — сервер обновит автоматически</div>
</div>
</div>
</div><!-- /shell -->
<script>
// ================================================================
const TASK_URL = 'https://cleverparking.intradesk.ru/task/list/';
const HISTORY_API = 'https://apigw.intradesk.ru/taskhistory/api/v2.0/lifetime';
// ── Auth state ──────────────────────────────────────────────────
let authToken = localStorage.getItem('intradesk_token');
let currentUser = null;
// ── App state ───────────────────────────────────────────────────
let allData = [];
let marksCache = {}; // { taskId: { resolved:{active,by,at}, ... } }
let notesCache = {}; // { taskId: { text, by, at } }
let lastKnownUpdatedAt = null;
let statusPollTimer = null;
let clientCountdownTimer = null;
let clientCountdownSec = 0;
let currentFilter = 'all';
let selectedTags = new Set();
let openNoteIdx = null;
let currentModalIdx = null;
let currentTab = 'last';
let staffIds = new Set();
let serverRefreshInterval = 300; // synced from server
const stateChips = { reply: true, resolved: true, hidden: true, unmarked: true, vyezdnye: true };
const STATE_CHIP_KEYS = ['reply', 'vyezdnye', 'resolved', 'hidden', 'unmarked'];
// ── Seen / unread (stays local) ─────────────────────────────────
const SEEN_KEY = 'intradesk_seen';
function loadSeen() { try { return JSON.parse(localStorage.getItem(SEEN_KEY) || '{}'); } catch { return {}; } }
function saveSeen(s) { try { localStorage.setItem(SEEN_KEY, JSON.stringify(s)); } catch {} }
function markSeen(id, upd) { const s = loadSeen(); s[id] = upd; saveSeen(s); }
function markAllRead() { const s = loadSeen(); allData.forEach(({task}) => { s[task.id] = task.updatedat; }); saveSeen(s); renderTable(); }
function isUnread(task) { const s = loadSeen(); return !s[task.id] || s[task.id] !== task.updatedat; }
// ── API helpers ─────────────────────────────────────────────────
async function apiGet(path) {
const res = await fetch(path, { headers: { 'Authorization': `Bearer ${authToken}` } });
if (res.status === 401) { doLogout(); return null; }
return res.json();
}
async function apiPost(path, body) {
const res = await fetch(path, {
method: 'POST',
headers: { 'Authorization': `Bearer ${authToken}`, 'Content-Type': 'application/json' },
body: JSON.stringify(body)
});
if (res.status === 401) { doLogout(); return null; }
return res.json();
}
// ── Auth ─────────────────────────────────────────────────────────
async function doLogin() {
const username = document.getElementById('login-username').value.trim();
const password = document.getElementById('login-password').value;
const errEl = document.getElementById('login-error');
errEl.textContent = '';
if (!username || !password) { errEl.textContent = 'Заполните все поля'; return; }
const res = await fetch('/api/auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ username, password })
});
const data = await res.json();
if (!res.ok) { errEl.textContent = data.error || 'Ошибка входа'; return; }
authToken = data.token;
localStorage.setItem('intradesk_token', authToken);
currentUser = { username: data.username };
showApp();
init();
}
document.addEventListener('keydown', e => {
if (e.key === 'Enter' && !document.getElementById('login-overlay').classList.contains('hidden')) {
doLogin();
}
});
function doLogout() {
localStorage.removeItem('intradesk_token');
authToken = null;
currentUser = null;
if (statusPollTimer) clearInterval(statusPollTimer);
if (clientCountdownTimer) clearInterval(clientCountdownTimer);
stopSSE();
document.getElementById('app-shell').style.display = 'none';
document.getElementById('login-overlay').classList.remove('hidden');
document.getElementById('login-password').value = '';
}
function showApp() {
document.getElementById('login-overlay').classList.add('hidden');
document.getElementById('app-shell').style.display = 'block';
document.getElementById('user-label').textContent = currentUser.username;
}
// ── Init ─────────────────────────────────────────────────────────
async function init() {
const status = await apiGet('/api/status');
if (!status) return;
serverRefreshInterval = status.auto_refresh_interval || 300;
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer();
startSSE();
startStatusPolling();
loadSettings();
}
// ── Load data from server ────────────────────────────────────────
async function loadMarks() {
const data = await apiGet('/api/marks');
if (data) marksCache = data;
}
async function loadNotes() {
const data = await apiGet('/api/notes');
if (data) notesCache = data;
}
async function loadTasksFromServer(silent = false) {
if (!silent) showLoading();
const data = await apiGet('/api/tasks');
if (!data) return;
allData = [];
for (const task of data.tasks || []) {
allData.push({ task, lastComment: extractLastComment(task) });
}
lastKnownUpdatedAt = data.updated_at;
document.getElementById('total-loaded').textContent = allData.length;
document.getElementById('last-update').textContent = data.updated_at
? new Date(data.updated_at).toLocaleTimeString('ru-RU') : '—';
document.getElementById('stats-bar').style.display = 'flex';
document.getElementById('filter-row').style.display = 'flex';
document.getElementById('state-filter-row').classList.add('visible');
document.getElementById('update-banner').classList.remove('visible');
renderTagFilter();
renderTable();
}
// ── Manual "Загрузить заявки" ─────────────────────────────────────
async function loadTasks() {
const btn = document.getElementById('load-btn');
btn.disabled = true;
btn.textContent = '⏳ Загружаю...';
try {
const r = await apiPost('/api/tasks/refresh', {});
if (r?.error) { alert('Ошибка: ' + r.error); return; }
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer(true);
} catch(e) {
alert('Ошибка загрузки: ' + e.message);
} finally {
btn.disabled = false;
btn.textContent = '▶ Загрузить / обновить заявки';
}
}
async function reloadData() {
await Promise.all([loadMarks(), loadNotes()]);
await loadTasksFromServer(true);
}
// ── Status polling ────────────────────────────────────────────────
function startStatusPolling() {
if (statusPollTimer) clearInterval(statusPollTimer);
clientCountdownSec = serverRefreshInterval;
updateCountdownDisplay();
statusPollTimer = setInterval(async () => {
const status = await apiGet('/api/status');
if (!status) return;
if (status.last_updated && status.last_updated !== lastKnownUpdatedAt) {
document.getElementById('update-banner').classList.add('visible');
}
}, 30000); // каждые 30с проверяем статус
// Обратный отсчёт для пользователя
if (clientCountdownTimer) clearInterval(clientCountdownTimer);
clientCountdownTimer = setInterval(() => {
clientCountdownSec--;
if (clientCountdownSec < 0) clientCountdownSec = serverRefreshInterval;
updateCountdownDisplay();
}, 1000);
}
function updateCountdownDisplay() {
const el = document.getElementById('refresh-countdown');
if (!el || clientCountdownSec <= 0) { if(el) el.textContent = ''; return; }
const m = Math.floor(clientCountdownSec / 60);
const s = clientCountdownSec % 60;
el.textContent = `🔄 ${m > 0 ? m + ':' + String(s).padStart(2,'0') : s + 's'}`;
}
// ── Settings ──────────────────────────────────────────────────────
async function loadSettings() {
const s = await apiGet('/api/settings');
if (!s) return;
document.getElementById('status-ids').value = s.status_ids || '';
document.getElementById('event-types').value = s.event_types || '';
document.getElementById('server-refresh-interval').value = s.auto_refresh_interval || '300';
document.getElementById('apikey-status').textContent = s.api_key_set ? '(ключ задан ✓)' : '(не задан!)';
}
async function saveSettings() {
const body = {
status_ids: document.getElementById('status-ids').value.trim(),
event_types: document.getElementById('event-types').value.trim(),
auto_refresh_interval: document.getElementById('server-refresh-interval').value,
};
const apiKey = document.getElementById('api-key').value.trim();
if (apiKey) body.api_key = apiKey;
const r = await apiPost('/api/settings', body);
if (r?.ok) {
serverRefreshInterval = parseInt(body.auto_refresh_interval) || 300;
startStatusPolling();
loadSettings();
document.getElementById('api-key').value = '';
alert('Настройки сохранены!');
}
}
async function createUser() {
const username = document.getElementById('new-user-login').value.trim();
const password = document.getElementById('new-user-pass').value;
const adminSecret = document.getElementById('new-user-secret').value;
const msgEl = document.getElementById('user-msg');
const r = await apiPost('/api/auth/register', { username, password, adminSecret });
if (r?.ok) {
msgEl.textContent = `✓ Пользователь "${username}" создан`;
document.getElementById('new-user-login').value = '';
document.getElementById('new-user-pass').value = '';
} else {
msgEl.style.color = 'var(--warn)';
msgEl.textContent = r?.error || 'Ошибка';
}
}
function toggleSettings() {
const panel = document.getElementById('settings-panel');
const toggle = document.getElementById('settings-toggle');
const isOpen = panel.classList.contains('open');
panel.classList.toggle('open', !isOpen);
toggle.classList.toggle('open', !isOpen);
}
// ── Marks (server-backed) ─────────────────────────────────────────
function isResolved(taskId) { return !!(marksCache[taskId]?.resolved?.active); }
function isNeedsReply(taskId) { return !!(marksCache[taskId]?.needs_reply?.active); }
function isVyezdnye(taskId) { return !!(marksCache[taskId]?.vyezdnye?.active); }
function isHidden(taskId) { return !!(marksCache[taskId]?.hidden?.active); }
function getMarkBy(taskId, type) {
const m = marksCache[taskId]?.[type];
return (m && m.active) ? m.by : null;
}
async function toggleMark(taskId, markType, e) {
e && e.stopPropagation();
const current = !!(marksCache[taskId]?.[markType]?.active);
const newValue = !current;
const now = new Date().toISOString();
const by = currentUser.username;
// Optimistic update
if (!marksCache[taskId]) marksCache[taskId] = {};
marksCache[taskId][markType] = { active: newValue, by, at: now };
if (newValue && markType === 'resolved') marksCache[taskId].needs_reply = { ...(marksCache[taskId].needs_reply||{}), active: false };
if (newValue && markType === 'needs_reply') marksCache[taskId].resolved = { ...(marksCache[taskId].resolved||{}), active: false };
renderTable();
await apiPost('/api/marks', { task_id: String(taskId), mark_type: markType, value: newValue });
}
function toggleResolved(taskId, e) { return toggleMark(taskId, 'resolved', e); }
function toggleNeedsReply(taskId, e) { return toggleMark(taskId, 'needs_reply', e); }
function toggleVyezdnye(taskId, e) { return toggleMark(taskId, 'vyezdnye', e); }
function toggleHidden(taskId, e) { return toggleMark(taskId, 'hidden', e); }
// ── Notes (server-backed) ─────────────────────────────────────────
function getNote(taskId) { return notesCache[taskId]?.text || ''; }
function getNoteBy(taskId) { return notesCache[taskId]?.by || null; }
function getNoteAt(taskId) { return notesCache[taskId]?.at || null; }
function openNoteEditor(idx, e) {
e && e.stopPropagation();
if (openNoteIdx === idx) { openNoteIdx = null; } else { openNoteIdx = idx; }
renderTable();
}
function closeNoteEditor() { openNoteIdx = null; renderTable(); }
async function saveNoteAndClose(idx, e) {
e && e.stopPropagation();
const ta = document.getElementById(`note-ta-${idx}`);
const taskId = String(allData[idx].task.id);
const text = ta ? ta.value : '';
const now = new Date().toISOString();
if (text.trim()) {
notesCache[taskId] = { text: text.trim(), by: currentUser.username, at: now };
} else {
delete notesCache[taskId];
}
openNoteIdx = null;
renderTable();
await apiPost('/api/notes', { task_id: taskId, text });
}
// ── Tag filter ────────────────────────────────────────────────────
function getTaskTags(task) {
const raw = task.tags?.data ?? task.tags ?? [];
return raw.map(t => ({ id: t.id ?? t, name: t.name ?? String(t) }));
}
function buildTagIndex() {
const map = new Map();
allData.forEach(({ task }) => {
getTaskTags(task).forEach(tag => {
if (!map.has(tag.name)) map.set(tag.name, { ...tag, count: 0 });
map.get(tag.name).count++;
});
});
return [...map.values()].sort((a, b) => b.count - a.count);
}
function renderTagFilter() {
const tags = buildTagIndex();
const wrap = document.getElementById('tag-filter-wrap');
const chips = document.getElementById('tag-chips');
const clear = document.getElementById('tag-clear');
if (!tags.length) { wrap.classList.remove('visible'); return; }
wrap.classList.add('visible');
chips.innerHTML = tags.map(tag =>
`<span class="tag-chip ${selectedTags.has(tag.name) ? 'active' : ''}"
onclick="toggleTagFilter(${JSON.stringify(tag.name)})">
<span class="tag-dot"></span>${esc(tag.name)}
<span style="font-size:9px;opacity:0.6;">${tag.count}</span>
</span>`
).join('');
clear.style.display = selectedTags.size ? 'inline' : 'none';
}
function toggleTagFilter(name) {
if (selectedTags.has(name)) selectedTags.delete(name); else selectedTags.add(name);
renderTagFilter(); renderTable();
}
function clearTagFilter() { selectedTags.clear(); renderTagFilter(); renderTable(); }
// ── State chips ───────────────────────────────────────────────────
function syncAllChip() {
const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]);
document.getElementById('chip-all').classList.toggle('on', allOn);
}
function toggleStateChip(name) {
stateChips[name] = !stateChips[name];
document.getElementById(`chip-${name}`).classList.toggle('on', stateChips[name]);
syncAllChip(); renderTable();
}
function toggleAllChip() {
const allOn = STATE_CHIP_KEYS.every(k => stateChips[k]);
const target = !allOn;
STATE_CHIP_KEYS.forEach(k => {
stateChips[k] = target;
document.getElementById(`chip-${k}`).classList.toggle('on', target);
});
document.getElementById('chip-all').classList.toggle('on', target);
renderTable();
}
// ── Render table ──────────────────────────────────────────────────
const COMMENT_TYPES = new Set([50, 55]);
const EVENT_TYPE_NAMES = {
10:'Смена статуса',20:'Смена приоритета',25:'Смена рейтинга',30:'Смена исполнителя',
40:'Смена группы',45:'Изм. доп. данных',50:'Комментарий',55:'Приватный комментарий',
60:'Добавлен наблюдатель',61:'Добавлен координатор',65:'Добавлена группа наблюдателей',
70:'Удалён наблюдатель',80:'Добавлен файл',85:'Добавлен прив. файл',90:'Удалён файл',
100:'Добавлен тег',110:'Удалён тег',120:'Изм. плановая дата',130:'Изм. дата реакции',
140:'Изменён сервис',150:'Изменён тип',160:'Изменён заявитель',165:'Изм. группа заявителя',
170:'Изм. доп. поле',200:'Изменено название',205:'Изменено описание',210:'Изм. бизнес-процесс',
280:'Архивировано',300:'Выполнен макрос',400:'Эскалация'
};
function extractLastComment(task) {
for (const entry of task.lifetime?.data || []) {
const ev = (entry.events?.data || []).find(e => COMMENT_TYPES.has(e.type));
if (ev) return {
username: entry.username, userid: entry.userid, usertype: entry.usertype,
eventat: entry.eventat, text: ev.stringvalue || '',
isPrivate: ev.type === 55, eventType: ev.type
};
}
return null;
}
function getAuthorType(item) {
if (!item.lastComment) return 'nocomment';
const { userid, usertype } = item.lastComment;
if (staffIds.size > 0) return staffIds.has(userid) ? 'internal' : 'external';
if (usertype === 10) return 'internal';
if (usertype === 20) return 'external';
return 'unknown';
}
function taskMatchesDateFilter(task) {
const val = document.getElementById('date-filter')?.value || '0';
if (val === '0') return true;
const now = new Date(); const upd = new Date(task.updatedat);
if (val === 'today') return upd.toDateString() === now.toDateString();
if (val === 'yesterday') { const y = new Date(now); y.setDate(y.getDate()-1); return upd.toDateString() === y.toDateString(); }
const cutoff = new Date(now); cutoff.setDate(cutoff.getDate() - parseInt(val));
return upd >= cutoff;
}
function setFilter(f, el) {
currentFilter = f;
document.querySelectorAll('.filter-tab').forEach(t => t.classList.remove('active'));
el.classList.add('active');
renderTable();
}
function renderTable() {
const search = document.getElementById('search-input')?.value?.toLowerCase() || '';
const skipEmpty = document.getElementById('opt-skip-empty')?.checked;
const limit = parseInt(document.getElementById('opt-limit')?.value) || 0;
let filtered = allData.filter(item => {
const taskId = String(item.task.id);
const hidden = isHidden(taskId);
const resolved = isResolved(taskId);
const needsReply = isNeedsReply(taskId);
const vyezdnye = isVyezdnye(taskId);
const isMarked = hidden || resolved || needsReply || vyezdnye;
const atype = getAuthorType(item);
if (needsReply && !stateChips.reply) return false;
if (resolved && !stateChips.resolved) return false;
if (hidden && !stateChips.hidden) return false;
if (vyezdnye && !stateChips.vyezdnye) return false;
if (!isMarked && !stateChips.unmarked) return false;
if (search && !(item.task.name?.toLowerCase().includes(search) || String(item.task.tasknumber).includes(search))) return false;
if (skipEmpty && !item.lastComment) return false;
if (!taskMatchesDateFilter(item.task)) return false;
if (selectedTags.size) {
const taskTagNames = getTaskTags(item.task).map(t => t.name);
if (![...selectedTags].some(t => taskTagNames.includes(t))) return false;
}
if (currentFilter === 'all') return true;
return atype === currentFilter;
});
if (limit > 0) filtered = filtered.slice(0, limit);
const countBase = (skipEmpty ? allData.filter(i => i.lastComment) : allData).filter(i => taskMatchesDateFilter(i.task));
document.getElementById('stat-external').textContent = countBase.filter(i => getAuthorType(i) === 'external').length;
document.getElementById('stat-internal').textContent = countBase.filter(i => getAuthorType(i) === 'internal').length;
document.getElementById('stat-nocomment').textContent = allData.filter(i => getAuthorType(i) === 'nocomment').length;
document.getElementById('stat-total').textContent = countBase.length;
document.getElementById('stat-unread').textContent = countBase.filter(i => isUnread(i.task)).length;
if (!filtered.length) {
document.getElementById('table-wrap').innerHTML = `
<div class="state-box"><span class="icon">🔍</span>
<div class="msg">Заявок не найдено</div><div class="sub">Измените фильтр или поиск</div>
</div>`;
return;
}
const rowsHtml = filtered.flatMap(item => {
const { task, lastComment } = item;
const atype = getAuthorType(item);
const num = task.tasknumber || task.id;
const idx = allData.indexOf(item);
const taskId = String(task.id);
const hidden = isHidden(taskId);
const resolved = isResolved(taskId);
const needsReply = isNeedsReply(taskId);
const vyezdnye = isVyezdnye(taskId);
const note = getNote(taskId);
const noteBy = getNoteBy(taskId);
const noteAt = getNoteAt(taskId);
const chipClass = { external:'external', internal:'internal', unknown:'unknown', nocomment:'unknown' }[atype] || 'unknown';
let authorHtml = '<span class="author-chip unknown"><span class="dot unknown"></span>Нет комментариев</span>';
if (lastComment) {
const priv = lastComment.isPrivate ? ' <span style="font-size:9px;opacity:0.7;">🔒</span>' : '';
authorHtml = `<span class="author-chip ${chipClass}"><span class="dot ${chipClass}"></span>${esc(lastComment.username)}${priv}</span>`;
}
let timeHtml = '<div class="time-cell">—</div>';
if (lastComment) {
const dt = new Date(lastComment.eventat);
const agoCls = (Date.now() - dt) < 3600000 ? 'fresh' : 'old';
timeHtml = `<div class="time-cell">${fmtDate(dt)}<div class="time-ago ${agoCls}">${timeAgo(dt)}</div></div>`;
}
let createdHtml = '<div class="time-cell">—</div>';
if (task.createdat) createdHtml = `<div class="time-cell">${fmtDate(new Date(task.createdat))}</div>`;
const preview = lastComment?.text
? `<div class="comment-preview">${esc(stripHtml(lastComment.text))}</div>`
: '<span style="color:var(--muted);font-size:11px;">—</span>';
const unread = isUnread(task);
const dotColor = atype === 'internal' ? 'var(--accent)' : atype === 'external' ? 'var(--warn)' : 'var(--muted)';
const unreadDot = unread ? `<span class="unread-dot" style="background:${dotColor}"></span>` : '';
let rowClass = '';
if (unread) rowClass += ' unread';
if (resolved) rowClass += ' resolved';
if (needsReply) rowClass += ' needs-reply';
if (vyezdnye) rowClass += ' row-vyezdnye';
if (hidden) rowClass += ' row-hidden';
// Action buttons с атрибуцией
const resolvedBy = getMarkBy(taskId, 'resolved');
const replyBy = getMarkBy(taskId, 'needs_reply');
const vyezdnyeBy = getMarkBy(taskId, 'vyezdnye');
const hiddenBy = getMarkBy(taskId, 'hidden');
const resolveLabel = resolved ? `✓ Решено${resolvedBy ? ` (${resolvedBy})` : ''}` : '○ Решено';
const needsReplyLabel = needsReply ? `✉ Ответить${replyBy ? ` (${replyBy})` : ''}` : '✉ Нужно ответить';
const vyezdnyeLabel = vyezdnye ? `🚗 Выездная${vyezdnyeBy ? ` (${vyezdnyeBy})` : ''}` : '🚗 Выездная';
const hideLabel = hidden ? `👁 Показать${hiddenBy ? ` (${hiddenBy})` : ''}` : '⊘ Скрыть';
const noteMetaHtml = note
? `<div class="note-meta">${noteBy ? `👤 ${esc(noteBy)}` : ''}${noteAt ? ` · ${fmtDate(new Date(noteAt))}` : ''}</div>`
: '';
const noteColContent = note
? `<div class="note-col-text" onclick="openNoteEditor(${idx},event)">📝 ${esc(note)}</div>${noteMetaHtml}`
: `<div class="note-col-empty" onclick="openNoteEditor(${idx},event)">+ заметка</div>`;
const mainRow = `<tr onclick="openModal(${idx})" data-idx="${idx}" class="${rowClass.trim()}">
<td>
<a class="task-link" href="${TASK_URL}${num}" target="_blank" onclick="event.stopPropagation()">
<span class="task-num">#${num}</span>${unreadDot}
</a>
<div class="task-name" title="${esc(task.name)}">${esc(task.name)}</div>
</td>
<td>${authorHtml}</td>
<td>${timeHtml}</td>
<td class="hide-m">${createdHtml}</td>
<td class="hide-m">${preview}</td>
<td class="note-col" onclick="event.stopPropagation()">
<div class="note-col-inner">
${noteColContent}
<div class="row-actions">
<button class="act-btn ${resolved ? 'active-resolve' : ''}" onclick="toggleResolved('${taskId}',event)">${resolveLabel}</button>
<button class="act-btn ${needsReply ? 'active-needs-reply' : ''}" onclick="toggleNeedsReply('${taskId}',event)">${needsReplyLabel}</button>
<button class="act-btn ${vyezdnye ? 'active-vyezdnye' : ''}" onclick="toggleVyezdnye('${taskId}',event)">${vyezdnyeLabel}</button>
<button class="act-btn ${hidden ? 'active-hide' : ''}" onclick="toggleHidden('${taskId}',event)">${hideLabel}</button>
</div>
</div>
</td>
</tr>`;
if (openNoteIdx === idx) {
const noteEditorRow = `<tr class="note-editor-row" onclick="event.stopPropagation()">
<td colspan="6">
<div class="note-editor-wrap">
<textarea id="note-ta-${idx}" placeholder="Введите заметку к заявке #${num}...">${esc(note)}</textarea>
<div style="display:flex;flex-direction:column;gap:6px;">
<button class="note-save-btn" onclick="saveNoteAndClose(${idx},event)">Сохранить</button>
<button class="note-cancel-btn" onclick="closeNoteEditor()">Отмена</button>
</div>
</div>
</td>
</tr>`;
return [mainRow, noteEditorRow];
}
return [mainRow];
}).join('');
document.getElementById('table-wrap').innerHTML = `
<table>
<thead><tr>
<th>Заявка / Объект</th>
<th>Последний комментарий</th>
<th>Время комментария</th>
<th class="hide-m">Дата создания</th>
<th class="hide-m">Превью</th>
<th>Заметки / Статус</th>
</tr></thead>
<tbody>${rowsHtml}</tbody>
</table>`;
if (openNoteIdx !== null) {
const ta = document.getElementById(`note-ta-${openNoteIdx}`);
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
}
}
// ── Modal ─────────────────────────────────────────────────────────
function openModal(idx) {
currentModalIdx = idx; currentTab = 'last';
const { task, lastComment } = allData[idx];
const num = task.tasknumber || task.id;
markSeen(task.id, task.updatedat);
const targetRow = document.querySelector(`tbody tr[data-idx="${idx}"]`);
if (targetRow) {
targetRow.classList.remove('unread');
const dot = targetRow.querySelector('.unread-dot');
if (dot) dot.remove();
}
document.getElementById('m-num').textContent = `#${num}`;
document.getElementById('m-name').textContent = task.name || '—';
document.getElementById('m-link').href = `${TASK_URL}${num}`;
document.getElementById('tab-last').classList.add('active');
document.getElementById('tab-history').classList.remove('active');
renderLastCommentTab(lastComment);
document.getElementById('modal').classList.add('open');
}
function renderLastCommentTab(lastComment) {
const atype = lastComment ? getAuthorType({ task: allData[currentModalIdx].task, lastComment }) : 'nocomment';
const chipClass = { external:'external', internal:'internal', unknown:'unknown', nocomment:'unknown' }[atype] || 'unknown';
const authorChip = lastComment
? `<span class="author-chip ${chipClass}"><span class="dot ${chipClass}"></span>${esc(lastComment.username)}</span>`
: '—';
const commentBlock = lastComment?.text
? `<div class="modal-comment-text">${sanitizeHtml(lastComment.text)}</div>`
: '<div class="modal-no-comment">Комментариев не было или текст пустой</div>';
document.getElementById('modal-body').innerHTML = `
<div class="modal-meta">
<div class="modal-meta-row"><span class="modal-meta-label">Автор</span><span class="modal-meta-value">${authorChip}</span></div>
<div class="modal-meta-row"><span class="modal-meta-label">userid</span><span class="modal-meta-value">${lastComment ? lastComment.userid : '—'}</span></div>
<div class="modal-meta-row"><span class="modal-meta-label">usertype</span><span class="modal-meta-value">${lastComment ? (lastComment.usertype===20?'20 (клиент)':lastComment.usertype===10?'10 (сотрудник)':lastComment.usertype) : '—'}</span></div>
<div class="modal-meta-row"><span class="modal-meta-label">Время</span><span class="modal-meta-value">${lastComment ? new Date(lastComment.eventat).toLocaleString('ru-RU') : '—'}</span></div>
<div class="modal-meta-row"><span class="modal-meta-label">event type</span><span class="modal-meta-value">${lastComment ? `${lastComment.eventType}${EVENT_TYPE_NAMES[lastComment.eventType]||'неизвестно'}` : '—'}</span></div>
</div>
<hr class="modal-divider">
<div class="modal-section-label">${lastComment?.isPrivate ? '🔒 Приватный комментарий' : 'Текст комментария'}</div>
${commentBlock}`;
}
async function renderHistoryTab() {
const task = allData[currentModalIdx].task;
const apiKey = ''; // History API вызывается с сервера в будущем; пока прямой вызов с клиента
const evTypes = document.getElementById('event-types')?.value?.trim() || '';
document.getElementById('modal-body').innerHTML = `<div class="history-loading"><div class="spinner"></div>Загружаю историю...</div>`;
let entries = [];
try {
// Для истории используем API key из настроек, запрашиваем через сервер
const res = await apiGet(`/api/tasks/history/${task.id}`);
if (res) { entries = res.entries || []; }
} catch {}
if (!entries.length) {
document.getElementById('modal-body').innerHTML = `<div class="modal-no-comment">История недоступна или пуста</div>`;
return;
}
const items = entries.map((entry, i) => {
const isStaff = staffIds.size > 0 ? staffIds.has(entry.userid) : entry.usertype === 10;
const cls = isStaff ? 'is-staff' : 'is-client';
const dt = new Date(entry.eventat);
const evList = entry.events?.data || [];
const eventsHtml = evList.map(ev => {
const typeName = EVENT_TYPE_NAMES[ev.type] || `тип ${ev.type}`;
const isComment = COMMENT_TYPES.has(ev.type);
let bodyHtml = '';
if (isComment && ev.stringvalue) bodyHtml = `<div class="history-entry-text">${sanitizeHtml(ev.stringvalue)}</div>`;
else if (ev.type === 10 && ev.entitylanguagevalue) {
const from = ev.entitylanguagevalue.valueold?.name?.ru || '?';
const to = ev.entitylanguagevalue.valuenew?.name?.ru || '?';
bodyHtml = `<div class="history-event-pill">«${esc(from)}» → «${esc(to)}»</div>`;
} else if (ev.entityvalue) {
bodyHtml = `<div class="history-event-pill">«${esc(ev.entityvalue.valueold?.name||'?')}» → «${esc(ev.entityvalue.valuenew?.name||'?')}»</div>`;
}
return `<div class="history-event"><div class="history-event-type ${isComment?'is-comment':''}">${esc(typeName)}</div>${bodyHtml}</div>`;
}).join('');
return `${i>0?'<hr class="history-sep">':''}
<div class="history-entry">
<div class="history-entry-header">
<span class="history-entry-author ${cls}">${esc(entry.username)}</span>
<span style="font-size:10px;color:var(--muted)">uid:${entry.userid} · type:${entry.usertype}</span>
<span class="history-entry-time">${dt.toLocaleString('ru-RU')} · ${timeAgo(dt)}</span>
</div>
${eventsHtml||'<div class="modal-no-comment">Нет данных</div>'}
</div>`;
}).join('');
document.getElementById('modal-body').innerHTML = `
<div style="font-size:10px;color:var(--muted);margin-bottom:16px;">Событий: ${entries.length}</div>${items}`;
}
function switchTab(tab) {
currentTab = tab;
document.getElementById('tab-last').classList.toggle('active', tab === 'last');
document.getElementById('tab-history').classList.toggle('active', tab === 'history');
if (tab === 'last') renderLastCommentTab(allData[currentModalIdx].lastComment);
else renderHistoryTab();
}
function closeModal() { document.getElementById('modal').classList.remove('open'); }
function closeModalOnOverlay(e) { if (e.target === document.getElementById('modal')) closeModal(); }
document.addEventListener('keydown', e => { if (e.key === 'Escape') closeModal(); });
function promptMarkAllRead() {
const count = allData.filter(i => isUnread(i.task)).length;
if (!count) return;
document.getElementById('confirm-count').textContent = count;
document.getElementById('confirm-modal').classList.add('open');
}
// ── Export ────────────────────────────────────────────────────────
function buildExportData() {
return allData.map(({ task }) => {
const id = String(task.id);
const num = task.tasknumber || task.id;
const nt = notesCache[id];
return {
id,
task_number: num,
name: task.name || '',
note: nt?.text || '',
note_by: nt?.by || '',
note_updated: nt?.at ? fmtDate(new Date(nt.at)) : '',
resolved: isResolved(id),
resolved_by: getMarkBy(id, 'resolved') || '',
needs_reply: isNeedsReply(id),
reply_by: getMarkBy(id, 'needs_reply') || '',
vyezdnye: isVyezdnye(id),
vyezdnye_by: getMarkBy(id, 'vyezdnye') || '',
hidden: isHidden(id),
hidden_by: getMarkBy(id, 'hidden') || '',
};
}).sort((a, b) => String(a.task_number).localeCompare(String(b.task_number), undefined, { numeric: true }));
}
function triggerDownload(content, filename, mime) {
const a = document.createElement('a');
a.href = URL.createObjectURL(new Blob([content], { type: mime }));
a.download = filename; a.click();
}
function exportCSV() {
const data = buildExportData();
const header = ['ID','Номер','Название','Заметка','Заметка — кто','Заметка — когда','Решено','Решено — кто','Нужно ответить','Ответить — кто','Выездная','Выездная — кто','Скрыто','Скрыто — кто'];
const rows = data.map(r => [
r.id, r.task_number,
`"${r.name.replace(/"/g,'""')}"`,
`"${r.note.replace(/"/g,'""')}"`,
r.note_by, r.note_updated,
r.resolved ?'Да':'Нет', r.resolved_by,
r.needs_reply ?'Да':'Нет', r.reply_by,
r.vyezdnye ?'Да':'Нет', r.vyezdnye_by,
r.hidden ?'Да':'Нет', r.hidden_by,
].join(';'));
triggerDownload('\uFEFF' + [header.join(';'), ...rows].join('\n'), `intradesk_${datestamp()}.csv`, 'text/csv;charset=utf-8');
}
function exportJSON() {
triggerDownload(JSON.stringify(buildExportData(), null, 2), `intradesk_${datestamp()}.json`, 'application/json');
}
function exportTXT() {
const lines = buildExportData().map(r => {
const flags = [
r.resolved ? `РЕШЕНО (${r.resolved_by})` : '',
r.needs_reply ? `НУЖНО ОТВЕТИТЬ (${r.reply_by})` : '',
r.vyezdnye ? `ВЫЕЗДНАЯ (${r.vyezdnye_by})` : '',
r.hidden ? `СКРЫТО (${r.hidden_by})` : '',
].filter(Boolean).join(', ');
return [
`#${r.task_number}${r.name}`,
flags ? ` Статус: ${flags}` : '',
r.note ? ` Заметка (${r.note_by}): ${r.note}` : '',
'',
].filter(l => l !== undefined && l !== '').join('\n');
});
triggerDownload(lines.join('\n'), `intradesk_${datestamp()}.txt`, 'text/plain;charset=utf-8');
}
// ── Utils ─────────────────────────────────────────────────────────
function esc(str) {
if (!str) return '';
return String(str).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
}
function fmtDate(dt) {
return `${dt.toLocaleDateString('ru-RU',{day:'2-digit',month:'2-digit',year:'2-digit'})} ${dt.toLocaleTimeString('ru-RU',{hour:'2-digit',minute:'2-digit'})}`;
}
function timeAgo(date) {
const m = Math.floor((Date.now() - date) / 60000);
if (m < 1) return 'только что';
if (m < 60) return `${m} мин назад`;
const h = Math.floor(m / 60);
if (h < 24) return `${h} ч назад`;
return `${Math.floor(h / 24)} д назад`;
}
function datestamp() {
const d = new Date();
return `${d.getFullYear()}${String(d.getMonth()+1).padStart(2,'0')}${String(d.getDate()).padStart(2,'0')}`;
}
function showLoading() {
document.getElementById('table-wrap').innerHTML = `
<div class="state-box"><div class="spinner"></div><div class="msg">Загружаю заявки...</div></div>`;
}
function sanitizeHtml(raw) {
if (!raw) return '';
if (!/<[a-z]/i.test(raw)) return esc(raw);
const tmp = document.createElement('div');
tmp.innerHTML = raw;
tmp.querySelectorAll('script,style,intradesk-initiator-notification-signature').forEach(el => el.remove());
tmp.querySelectorAll('intradesk-quote').forEach(el => {
const q = document.createElement('blockquote');
q.className = 'intradesk-quote-block';
q.innerHTML = el.innerHTML;
el.replaceWith(q);
});
tmp.querySelectorAll('*').forEach(el => {
['style','class','color','face','size','bgcolor','align','contenteditable','quote-parenttype','quote-parentid']
.forEach(a => el.removeAttribute(a));
if (el.tagName === 'FONT') { const s = document.createElement('span'); s.innerHTML = el.innerHTML; el.replaceWith(s); }
});
return tmp.innerHTML;
}
function stripHtml(raw) {
if (!raw) return '';
const t = document.createElement('div');
t.innerHTML = raw;
return (t.textContent || t.innerText || '').replace(/\s+/g, ' ').trim();
}
// ── Toast ─────────────────────────────────────────────────────────
function showToast(text, type = 'mark', duration = 4000) {
const wrap = document.getElementById('toast-wrap');
const el = document.createElement('div');
el.className = `toast ${type}`;
el.textContent = text;
wrap.appendChild(el);
setTimeout(() => el.remove(), duration);
}
const MARK_LABELS = {
resolved: 'Решено', needs_reply: 'Нужно ответить',
vyezdnye: 'Выездная', hidden: 'Скрыто'
};
// ── SSE ───────────────────────────────────────────────────────────
let sseSource = null;
function startSSE() {
if (sseSource) sseSource.close();
// Передаём токен через query-параметр т.к. EventSource не поддерживает заголовки
sseSource = new EventSource(`/api/events?token=${encodeURIComponent(authToken)}`);
sseSource.addEventListener('mark', e => {
const d = JSON.parse(e.data);
// Обновляем локальный кэш
if (!marksCache[d.task_id]) marksCache[d.task_id] = {};
marksCache[d.task_id][d.mark_type] = { active: d.value, by: d.by, at: d.at };
if (d.also_cleared) marksCache[d.task_id][d.also_cleared] = { ...(marksCache[d.task_id][d.also_cleared] || {}), active: false };
const label = MARK_LABELS[d.mark_type] || d.mark_type;
const action = d.value ? `✓ поставил «${label}»` : `✕ снял «${label}»`;
showToast(`👤 ${d.by} ${action} на #${d.task_id}`, 'mark');
renderTable();
});
sseSource.addEventListener('note', e => {
const d = JSON.parse(e.data);
if (d.text) {
notesCache[d.task_id] = { text: d.text, by: d.by, at: d.at };
showToast(`📝 ${d.by} обновил заметку на #${d.task_id}`, 'note');
} else {
delete notesCache[d.task_id];
showToast(`📝 ${d.by} удалил заметку на #${d.task_id}`, 'note');
}
renderTable();
});
sseSource.addEventListener('tasks_updated', e => {
const d = JSON.parse(e.data);
document.getElementById('update-banner').classList.add('visible');
showToast(`🔄 Заявки обновлены на сервере (${d.count} шт.)`, 'reload', 6000);
});
sseSource.onerror = () => {
// При обрыве EventSource сам переподключается — ничего не делаем
};
}
function stopSSE() {
if (sseSource) { sseSource.close(); sseSource = null; }
}
window.addEventListener('DOMContentLoaded', () => {
if (authToken) {
// Пробуем восстановить сессию
try {
const payload = JSON.parse(atob(authToken.split('.')[1]));
if (payload.exp * 1000 > Date.now()) {
currentUser = { username: payload.username };
showApp();
init();
return;
}
} catch {}
localStorage.removeItem('intradesk_token');
authToken = null;
}
// Показываем логин
document.getElementById('login-overlay').classList.remove('hidden');
});
</script>
</body>
</html>