1610 lines
79 KiB
HTML
1610 lines
79 KiB
HTML
<!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,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||
}
|
||
|
||
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>
|