1211 lines
38 KiB
HTML
1211 lines
38 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover">
|
||
<title>Заявка</title>
|
||
<script src="https://telegram.org/js/telegram-web-app.js"></script>
|
||
<style>
|
||
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
|
||
|
||
:root {
|
||
--bg: var(--tg-theme-bg-color, #ffffff);
|
||
--bg2: var(--tg-theme-secondary-bg-color, #f1f1f1);
|
||
--text: var(--tg-theme-text-color, #000000);
|
||
--hint: var(--tg-theme-hint-color, #999999);
|
||
--link: var(--tg-theme-link-color, #2481cc);
|
||
--btn: var(--tg-theme-button-color, #2481cc);
|
||
--btn-text: var(--tg-theme-button-text-color, #ffffff);
|
||
--divider: rgba(0,0,0,0.08);
|
||
--r: 12px;
|
||
--font: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
||
}
|
||
|
||
body {
|
||
background: var(--bg2);
|
||
color: var(--text);
|
||
font-family: var(--font);
|
||
font-size: 15px;
|
||
line-height: 1.45;
|
||
padding-bottom: env(safe-area-inset-bottom, 16px);
|
||
}
|
||
|
||
/* ── Loader ── */
|
||
#loader {
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
height: 70vh;
|
||
gap: 14px;
|
||
color: var(--hint);
|
||
font-size: 14px;
|
||
}
|
||
.spinner {
|
||
width: 32px; height: 32px;
|
||
border: 3px solid var(--divider);
|
||
border-top-color: var(--btn);
|
||
border-radius: 50%;
|
||
animation: spin .7s linear infinite;
|
||
}
|
||
@keyframes spin { to { transform: rotate(360deg); } }
|
||
|
||
/* ── Error ── */
|
||
#error-state {
|
||
display: none;
|
||
padding: 40px 24px;
|
||
text-align: center;
|
||
color: var(--hint);
|
||
}
|
||
#error-state .err-icon { font-size: 48px; margin-bottom: 12px; }
|
||
#error-state .err-msg { font-size: 15px; }
|
||
|
||
/* ── Content ── */
|
||
#content { display: none; animation: fadeIn .25s ease; }
|
||
@keyframes fadeIn { from { opacity: 0; transform: translateY(6px); } to { opacity: 1; transform: none; } }
|
||
|
||
/* ── Hero card ── */
|
||
.hero {
|
||
background: var(--bg);
|
||
padding: 18px 16px 16px;
|
||
border-bottom: 1px solid var(--divider);
|
||
}
|
||
.hero-meta {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
margin-bottom: 10px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.task-num {
|
||
font-size: 13px;
|
||
font-weight: 700;
|
||
color: var(--hint);
|
||
letter-spacing: 0.4px;
|
||
text-transform: uppercase;
|
||
}
|
||
.status-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
padding: 3px 9px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
}
|
||
.pill-open { background: rgba(40,180,99,0.15); color: #28b463; }
|
||
.pill-work { background: rgba(36,129,204,0.15); color: #2481cc; }
|
||
.pill-done { background: rgba(127,140,141,0.15);color: #7f8c8d; }
|
||
.pill-wait { background: rgba(230,126,34,0.15); color: #e67e22; }
|
||
.pill-other { background: rgba(127,140,141,0.15);color: #7f8c8d; }
|
||
|
||
.priority-pill {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
padding: 3px 9px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 500;
|
||
background: var(--bg2);
|
||
color: var(--hint);
|
||
}
|
||
.pri-high { background: rgba(231,76,60,0.12); color: #e74c3c; }
|
||
.pri-crit { background: rgba(192,57,43,0.15); color: #c0392b; }
|
||
.pri-low { background: rgba(127,140,141,0.1); color: #7f8c8d; }
|
||
|
||
.hero-title {
|
||
font-size: 17px;
|
||
font-weight: 600;
|
||
line-height: 1.35;
|
||
color: var(--text);
|
||
}
|
||
.to-badge {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 4px;
|
||
margin-top: 8px;
|
||
padding: 3px 9px;
|
||
border-radius: 20px;
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
background: rgba(230,126,34,0.15);
|
||
color: #e67e22;
|
||
}
|
||
|
||
/* ── Section wrapper ── */
|
||
.section {
|
||
margin: 10px 0 0;
|
||
background: var(--bg);
|
||
}
|
||
.section-title {
|
||
padding: 12px 16px 8px;
|
||
font-size: 12px;
|
||
font-weight: 700;
|
||
letter-spacing: 0.6px;
|
||
text-transform: uppercase;
|
||
color: var(--hint);
|
||
}
|
||
|
||
/* ── Info rows ── */
|
||
.info-row {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
padding: 10px 16px;
|
||
border-top: 1px solid var(--divider);
|
||
gap: 10px;
|
||
min-height: 44px;
|
||
}
|
||
.info-label {
|
||
font-size: 14px;
|
||
color: var(--hint);
|
||
flex: 0 0 130px;
|
||
padding-top: 1px;
|
||
}
|
||
.info-val {
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
font-weight: 500;
|
||
flex: 1;
|
||
word-break: break-word;
|
||
}
|
||
.info-val a {
|
||
color: var(--link);
|
||
text-decoration: none;
|
||
}
|
||
|
||
/* ── Description ── */
|
||
.desc-body {
|
||
padding: 2px 16px 14px;
|
||
font-size: 14px;
|
||
color: var(--text);
|
||
line-height: 1.6;
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
max-height: 200px;
|
||
overflow: hidden;
|
||
transition: max-height .3s ease;
|
||
position: relative;
|
||
}
|
||
.desc-body.expanded { max-height: 2000px; }
|
||
.desc-fade {
|
||
position: absolute;
|
||
bottom: 0; left: 0; right: 0;
|
||
height: 48px;
|
||
background: linear-gradient(transparent, var(--bg));
|
||
pointer-events: none;
|
||
}
|
||
.desc-body.expanded .desc-fade { display: none; }
|
||
.desc-toggle {
|
||
display: block;
|
||
padding: 4px 16px 12px;
|
||
font-size: 13px;
|
||
color: var(--link);
|
||
cursor: pointer;
|
||
background: none;
|
||
border: none;
|
||
font-family: var(--font);
|
||
text-align: left;
|
||
}
|
||
|
||
/* ── Attachments ── */
|
||
/* ── Attachments gallery ── */
|
||
.attach-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(3, minmax(0, 120px));
|
||
gap: 3px;
|
||
padding: 4px 4px 8px;
|
||
}
|
||
.attach-thumb {
|
||
aspect-ratio: 1;
|
||
border-radius: 6px;
|
||
overflow: hidden;
|
||
cursor: pointer;
|
||
position: relative;
|
||
background: var(--bg2);
|
||
}
|
||
.attach-thumb img {
|
||
width: 100%; height: 100%;
|
||
object-fit: cover;
|
||
display: block;
|
||
transition: transform .2s ease;
|
||
}
|
||
.attach-thumb:active img { transform: scale(.95); }
|
||
.attach-thumb-placeholder {
|
||
width: 100%; height: 100%;
|
||
display: flex; flex-direction: column;
|
||
align-items: center; justify-content: center;
|
||
gap: 4px;
|
||
cursor: pointer;
|
||
}
|
||
.attach-thumb-placeholder .ph-icon { font-size: 24px; }
|
||
.attach-thumb-placeholder .ph-name {
|
||
font-size: 10px;
|
||
color: var(--hint);
|
||
text-align: center;
|
||
padding: 0 4px;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
width: 100%;
|
||
}
|
||
.no-attach {
|
||
padding: 14px 16px;
|
||
font-size: 14px;
|
||
color: var(--hint);
|
||
border-top: 1px solid var(--divider);
|
||
}
|
||
|
||
/* ── Lightbox ── */
|
||
#lightbox {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: 1000;
|
||
background: rgba(0,0,0,0.92);
|
||
flex-direction: column;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
#lightbox.open { display: flex; }
|
||
#lightbox-img {
|
||
max-width: 95vw;
|
||
max-height: 80vh;
|
||
border-radius: 8px;
|
||
object-fit: contain;
|
||
}
|
||
#lightbox-spinner {
|
||
width: 36px; height: 36px;
|
||
border: 3px solid rgba(255,255,255,0.2);
|
||
border-top-color: #fff;
|
||
border-radius: 50%;
|
||
animation: spin .7s linear infinite;
|
||
}
|
||
#lightbox-name {
|
||
color: rgba(255,255,255,0.7);
|
||
font-size: 13px;
|
||
margin-top: 12px;
|
||
text-align: center;
|
||
padding: 0 20px;
|
||
}
|
||
#lightbox-close {
|
||
position: absolute;
|
||
top: 14px; right: 16px;
|
||
background: rgba(255,255,255,0.15);
|
||
border: none;
|
||
color: #fff;
|
||
font-size: 22px;
|
||
width: 38px; height: 38px;
|
||
border-radius: 50%;
|
||
cursor: pointer;
|
||
display: flex; align-items: center; justify-content: center;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
#lightbox-download {
|
||
margin-top: 10px;
|
||
padding: 8px 20px;
|
||
background: rgba(255,255,255,0.15);
|
||
border: 1px solid rgba(255,255,255,0.25);
|
||
border-radius: 20px;
|
||
color: #fff;
|
||
font-size: 13px;
|
||
cursor: pointer;
|
||
text-decoration: none;
|
||
backdrop-filter: blur(4px);
|
||
}
|
||
|
||
/* ── Timeline ── */
|
||
.timeline {
|
||
padding: 0 0 8px;
|
||
}
|
||
.tl-day {
|
||
padding: 16px 16px 4px;
|
||
font-size: 11px;
|
||
font-weight: 700;
|
||
text-transform: uppercase;
|
||
letter-spacing: 0.7px;
|
||
color: var(--hint);
|
||
}
|
||
|
||
/* Change event */
|
||
.ev-change {
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: 10px;
|
||
padding: 8px 16px;
|
||
}
|
||
.ev-dot {
|
||
width: 8px; height: 8px;
|
||
border-radius: 50%;
|
||
background: var(--hint);
|
||
margin-top: 5px;
|
||
flex-shrink: 0;
|
||
opacity: 0.5;
|
||
}
|
||
.ev-change-body { flex: 1; }
|
||
.ev-change-text {
|
||
font-size: 13.5px;
|
||
color: var(--text);
|
||
line-height: 1.4;
|
||
}
|
||
.ev-change-text .arrow { color: var(--hint); }
|
||
.ev-change-text .new-val { font-weight: 600; color: var(--text); }
|
||
.ev-time {
|
||
font-size: 11px;
|
||
color: var(--hint);
|
||
margin-top: 2px;
|
||
}
|
||
|
||
/* Comment */
|
||
.ev-comment {
|
||
display: flex;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
}
|
||
.ev-avatar {
|
||
width: 36px; height: 36px;
|
||
border-radius: 50%;
|
||
background: #d0d0d0;
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 18px;
|
||
flex-shrink: 0;
|
||
}
|
||
.ev-comment-inner { flex: 1; min-width: 0; }
|
||
.ev-comment-header {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
margin-bottom: 5px;
|
||
flex-wrap: wrap;
|
||
}
|
||
.ev-comment-time {
|
||
font-size: 12px;
|
||
color: var(--hint);
|
||
}
|
||
.ev-comment-author {
|
||
font-size: 13px;
|
||
font-weight: 600;
|
||
color: var(--text);
|
||
}
|
||
.ev-comment-label {
|
||
font-size: 12px;
|
||
color: var(--hint);
|
||
margin-bottom: 4px;
|
||
}
|
||
.ev-comment.private .ev-comment-author { color: #e67e22; }
|
||
.ev-comment-bubble {
|
||
background: var(--bg2);
|
||
border-radius: 4px 12px 12px 12px;
|
||
padding: 10px 12px;
|
||
}
|
||
.ev-comment.private .ev-comment-bubble {
|
||
background: rgba(230,126,34,0.08);
|
||
border-left: 3px solid rgba(230,126,34,0.4);
|
||
}
|
||
.ev-comment-text {
|
||
font-size: 14px;
|
||
line-height: 1.5;
|
||
color: var(--text);
|
||
white-space: pre-wrap;
|
||
word-break: break-word;
|
||
}
|
||
|
||
/* File in timeline */
|
||
.ev-file {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 16px;
|
||
font-size: 13px;
|
||
}
|
||
.ev-file-icon { font-size: 20px; flex-shrink: 0; }
|
||
|
||
.ev-file-thumb {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 8px 16px;
|
||
cursor: pointer;
|
||
}
|
||
.ev-file-thumb img {
|
||
width: 52px; height: 52px;
|
||
border-radius: 8px;
|
||
object-fit: cover;
|
||
flex-shrink: 0;
|
||
background: var(--bg2);
|
||
transition: transform .15s ease;
|
||
}
|
||
.ev-file-thumb:active img { transform: scale(.93); }
|
||
.ev-file-thumb-info {
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: 2px;
|
||
min-width: 0;
|
||
}
|
||
.ev-file-author { font-size: 12px; color: var(--hint); }
|
||
.ev-file-name {
|
||
font-size: 13px;
|
||
font-weight: 500;
|
||
color: var(--text);
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.ev-file-time { font-size: 11px; color: var(--hint); }
|
||
|
||
/* Empty state */
|
||
.tl-empty {
|
||
padding: 24px 16px;
|
||
font-size: 14px;
|
||
color: var(--hint);
|
||
text-align: center;
|
||
}
|
||
|
||
/* ── Responsible block ── */
|
||
.resp-row {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 10px;
|
||
padding: 10px 16px;
|
||
border-top: 1px solid var(--divider);
|
||
}
|
||
.resp-avatar {
|
||
width: 32px; height: 32px;
|
||
border-radius: 50%;
|
||
background: var(--btn);
|
||
color: var(--btn-text);
|
||
display: flex; align-items: center; justify-content: center;
|
||
font-size: 13px; font-weight: 700;
|
||
flex-shrink: 0;
|
||
}
|
||
.resp-name { font-size: 14px; font-weight: 500; }
|
||
.resp-label { font-size: 12px; color: var(--hint); }
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<div id="loader">
|
||
<div class="spinner"></div>
|
||
<span>Загрузка заявки…</span>
|
||
</div>
|
||
|
||
<div id="error-state">
|
||
<div class="err-icon">😞</div>
|
||
<div class="err-msg" id="err-msg">Заявка не найдена</div>
|
||
</div>
|
||
|
||
<div id="content"></div>
|
||
</div>
|
||
|
||
<script>
|
||
// ═══════════════════════════════════════
|
||
// TELEGRAM WEBAPPP INIT
|
||
// ═══════════════════════════════════════
|
||
const tg = window.Telegram?.WebApp;
|
||
if (tg) {
|
||
tg.ready();
|
||
tg.expand();
|
||
tg.BackButton.show();
|
||
tg.BackButton.onClick(() => tg.close());
|
||
}
|
||
|
||
// Authenticated fetch — добавляет токен из URL в каждый запрос
|
||
const _urlParams = new URLSearchParams(window.location.search);
|
||
const _taskToken = `${_urlParams.get('exp') || ''}:${_urlParams.get('sig') || ''}`;
|
||
|
||
function apiFetch(url) {
|
||
// Добавляем exp и sig к каждому API запросу
|
||
const exp = _urlParams.get('exp');
|
||
const sig = _urlParams.get('sig');
|
||
if (exp && sig) {
|
||
const sep = url.includes('?') ? '&' : '?';
|
||
url = `${url}${sep}exp=${exp}&sig=${sig}`;
|
||
}
|
||
return fetch(url);
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// CONSTANTS
|
||
// ═══════════════════════════════════════
|
||
const STATUSES = {
|
||
68046: { name: 'Открыта', cls: 'pill-open', icon: '🟢' },
|
||
68048: { name: 'Требует уточнения', cls: 'pill-wait', icon: '🟡' },
|
||
68051: { name: 'В работе', cls: 'pill-work', icon: '🔵' },
|
||
68045: { name: 'Выполнена', cls: 'pill-done', icon: '⚫' },
|
||
};
|
||
const PRIORITIES = {
|
||
29749: { name: 'Наивысший', cls: 'pri-crit' },
|
||
29598: { name: 'Критический', cls: 'pri-crit' },
|
||
29597: { name: 'Высокий', cls: 'pri-high' },
|
||
29596: { name: 'Низкий', cls: 'pri-low' },
|
||
};
|
||
const FIELD_LABELS = {
|
||
status: 'Статус', priority: 'Приоритет', executor: 'Исполнитель',
|
||
executorgroup: 'Группа', service: 'Услуга', tasktype: 'Тип заявки',
|
||
initiator: 'Инициатор', resolutiondateplan: 'Плановый срок',
|
||
closedat: 'Закрыта', description: 'Описание', name: 'Название',
|
||
workflow: 'Бизнес-процесс', sla: 'SLA', tags: 'Теги',
|
||
};
|
||
const FILE_BLOCKS = new Set(['file', 'files', 'taskfiles', 'attachment', 'attachments',
|
||
'privateattachment', 'privateattachments']);
|
||
const IGNORE_BLOCKS = new Set(['lifetime','updatedat','updatedby','updatedchannel',
|
||
'reactiondatefact','resolutiondatefact','resolutionleftminutes',
|
||
'reactiondateplan','ismassincidentactive']);
|
||
const FILE_EXTS = {
|
||
pdf:'📄', doc:'📝', docx:'📝', xls:'📊', xlsx:'📊', ppt:'📋', pptx:'📋',
|
||
jpg:'🖼️', jpeg:'🖼️', png:'🖼️', gif:'🖼️', webp:'🖼️',
|
||
zip:'🗜️', rar:'🗜️', mp4:'🎥', mp3:'🎵', txt:'📄',
|
||
};
|
||
|
||
// ═══════════════════════════════════════
|
||
// HELPERS
|
||
// ═══════════════════════════════════════
|
||
function esc(s) {
|
||
return String(s ?? '').replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
||
}
|
||
|
||
function shortenName(name) {
|
||
if (!name) return '';
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) return `${parts[0]} ${parts[1][0]}.`;
|
||
return name;
|
||
}
|
||
|
||
function fmtDate(s) {
|
||
if (!s) return null;
|
||
try {
|
||
const d = new Date(s);
|
||
return d.toLocaleString('ru-RU', {
|
||
day: '2-digit', month: '2-digit', year: 'numeric',
|
||
hour: '2-digit', minute: '2-digit'
|
||
});
|
||
} catch { return s; }
|
||
}
|
||
|
||
function fmtDateShort(s) {
|
||
if (!s) return null;
|
||
try {
|
||
const d = new Date(s);
|
||
return d.toLocaleString('ru-RU', {
|
||
day: '2-digit', month: '2-digit',
|
||
hour: '2-digit', minute: '2-digit'
|
||
});
|
||
} catch { return s; }
|
||
}
|
||
|
||
function fmtDayLabel(s) {
|
||
if (!s) return null;
|
||
try {
|
||
const d = new Date(s);
|
||
const today = new Date();
|
||
const yest = new Date(); yest.setDate(today.getDate() - 1);
|
||
const sameDay = (a, b) => a.toDateString() === b.toDateString();
|
||
if (sameDay(d, today)) return 'Сегодня';
|
||
if (sameDay(d, yest)) return 'Вчера';
|
||
return d.toLocaleDateString('ru-RU', { day: '2-digit', month: 'long', year: 'numeric' });
|
||
} catch { return s; }
|
||
}
|
||
|
||
function stripQuote(text) {
|
||
if (!text) return text;
|
||
return text.replace(/\[quote[^\]]*\].*?\[\/quote\]/gsi, '')
|
||
.replace(/\{quote[^\}]*\}.*?\{\/quote\}/gsi, '')
|
||
.replace(/<blockquote[^>]*>.*?<\/blockquote>/gsi, '')
|
||
.replace(/<quote[^>]*>.*?<\/quote>/gsi, '')
|
||
.trim();
|
||
}
|
||
|
||
function extractName(v) {
|
||
if (!v) return null;
|
||
let raw = v.currentname ?? v.name ?? v.Name ?? null;
|
||
if (raw && typeof raw === 'object') raw = raw.ru ?? raw.en ?? null;
|
||
if (raw) return String(raw).trim() || null;
|
||
return null;
|
||
}
|
||
|
||
function fileIcon(fname) {
|
||
if (!fname) return '📎';
|
||
const ext = (fname.split('.').pop() || '').toLowerCase();
|
||
return FILE_EXTS[ext] || '📎';
|
||
}
|
||
|
||
function getFileName(strval) {
|
||
try {
|
||
const obj = JSON.parse(strval);
|
||
if (obj && obj.Name) return {
|
||
name: obj.Name,
|
||
uploader: obj.UploadedBy || '',
|
||
fileId: obj.Id || null,
|
||
contentType: obj.ContentType || '',
|
||
size: obj.Size || 0,
|
||
};
|
||
} catch {}
|
||
return { name: strval || 'файл', uploader: '', fileId: null, contentType: '', size: 0 };
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// EVENT PARSER
|
||
// ═══════════════════════════════════════
|
||
function parseEvent(ev) {
|
||
const type = ev.type;
|
||
const blockname = (ev.blockname || '').toLowerCase();
|
||
const username = shortenName(ev.username || '');
|
||
const date = ev.createdat || ev.date || ev.updatedat || '';
|
||
|
||
// Skip creation event and ignored blocks
|
||
if (ev.lifetimetype === 10) return null;
|
||
if (IGNORE_BLOCKS.has(blockname)) return null;
|
||
|
||
// Public comment
|
||
if (type === 50) {
|
||
let text = stripQuote(ev.stringvalue || '');
|
||
if (!text) return null;
|
||
return { kind: 'comment', author: username, text, date, private: false };
|
||
}
|
||
|
||
// Private comment
|
||
if (type === 55) {
|
||
let text = stripQuote(ev.stringvalue || '');
|
||
if (!text) return null;
|
||
return { kind: 'comment', author: username, text, date, private: true };
|
||
}
|
||
|
||
// File attachment
|
||
if (FILE_BLOCKS.has(blockname) || type === 80 || type === 85) {
|
||
const strval = ev.stringvalue || '';
|
||
const { name, uploader, fileId, contentType, size } = getFileName(strval);
|
||
return { kind: 'file', filename: name, uploader: shortenName(uploader), author: username, date,
|
||
private: type === 85, fileId, contentType, size };
|
||
}
|
||
|
||
// Skip certain service events
|
||
if ([185, 250, 195, 190, 180, 260, 270].includes(type)) return null;
|
||
|
||
// Entity change
|
||
const entityData = ev.entityvalue || ev.entitylanguagevalue;
|
||
if (entityData) {
|
||
const valNew = entityData.valuenew || {};
|
||
const valOld = entityData.valueold;
|
||
const newStr = extractName(valNew);
|
||
const oldStr = valOld ? extractName(valOld) : null;
|
||
const label = FIELD_LABELS[blockname] || blockname;
|
||
let text = '';
|
||
if (newStr && oldStr) text = `${label}: ${oldStr} → ${newStr}`;
|
||
else if (newStr) text = `${label}: ${newStr}`;
|
||
else if (oldStr) text = `${label}: ${oldStr} → снят`;
|
||
if (text) return { kind: 'change', text, author: username, date };
|
||
return null;
|
||
}
|
||
|
||
// String value
|
||
const strVal = ev.stringvalue;
|
||
if (strVal) {
|
||
const label = FIELD_LABELS[blockname] || blockname || `Событие ${type}`;
|
||
const short = strVal.length > 120 ? strVal.slice(0, 120) + '…' : strVal;
|
||
return { kind: 'change', text: `${label}: ${short}`, author: username, date };
|
||
}
|
||
|
||
// Bare event (no value)
|
||
if (type && type !== 185 && type !== 250) {
|
||
const label = FIELD_LABELS[blockname] || blockname;
|
||
if (label) return { kind: 'change', text: label, author: username, date };
|
||
}
|
||
|
||
return null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// RENDER
|
||
// ═══════════════════════════════════════
|
||
function renderStatusPill(statusId) {
|
||
const s = STATUSES[statusId];
|
||
if (!s) return `<span class="status-pill pill-other">${esc(statusId || '—')}</span>`;
|
||
return `<span class="status-pill ${s.cls}">${esc(s.name)}</span>`;
|
||
}
|
||
|
||
function renderPriorityPill(priorityId, priorityName) {
|
||
const p = PRIORITIES[priorityId];
|
||
const name = priorityName || (p ? p.name : (priorityId ? String(priorityId) : null));
|
||
if (!name) return '';
|
||
const cls = p ? p.cls : '';
|
||
return `<span class="priority-pill ${cls}">↑ ${esc(name)}</span>`;
|
||
}
|
||
|
||
function renderInfoRow(label, value) {
|
||
if (!value) return '';
|
||
return `<div class="info-row">
|
||
<span class="info-label">${esc(label)}</span>
|
||
<span class="info-val">${value}</span>
|
||
</div>`;
|
||
}
|
||
|
||
function isImage(contentType, name) {
|
||
if (contentType && contentType.startsWith('image/')) return true;
|
||
const ext = (name || '').split('.').pop().toLowerCase();
|
||
return ['jpg','jpeg','png','gif','webp','bmp','svg'].includes(ext);
|
||
}
|
||
|
||
function renderAttachments(attachments) {
|
||
if (!attachments || !attachments.length) {
|
||
return '<div class="no-attach">Нет вложений</div>';
|
||
}
|
||
const items = attachments.map(a => {
|
||
const name = a.name || a.Name || 'файл';
|
||
const fid = a.fileId || null;
|
||
const img = isImage(a.contentType, name);
|
||
if (fid && img) {
|
||
return `<div class="attach-thumb" onclick="openLightbox('/api/files/${fid}', '${esc(name).replace(/'/g,"\\'")}')">
|
||
<img src="/api/files/${fid}?thumb=1" alt="${esc(name)}" loading="lazy" onerror="imgError(this)">
|
||
</div>`;
|
||
}
|
||
if (fid) {
|
||
const icon = fileIcon(name);
|
||
return `<div class="attach-thumb" onclick="openLightbox('/api/files/${fid}', '${esc(name)}', false)">
|
||
<div class="attach-thumb-placeholder">
|
||
<span class="ph-icon">${icon}</span>
|
||
<span class="ph-name">${esc(name)}</span>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
// No fileId — show placeholder only
|
||
const icon = fileIcon(name);
|
||
return `<div class="attach-thumb">
|
||
<div class="attach-thumb-placeholder">
|
||
<span class="ph-icon">${icon}</span>
|
||
<span class="ph-name">${esc(name)}</span>
|
||
</div>
|
||
</div>`;
|
||
}).join('');
|
||
return `<div class="attach-grid">${items}</div>`;
|
||
}
|
||
|
||
function imgError(el) {
|
||
const icon = fileIcon(el.alt || '');
|
||
const name = el.alt || 'файл';
|
||
el.parentNode.innerHTML = `<div class="attach-thumb-placeholder"><span class="ph-icon">${icon}</span><span class="ph-name">${esc(name)}</span></div>`;
|
||
}
|
||
|
||
function avatarInitials(name) {
|
||
if (!name) return '👤';
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) return (parts[0][0] + parts[1][0]).toUpperCase();
|
||
return name[0].toUpperCase();
|
||
}
|
||
|
||
function renderTimeline(events) {
|
||
if (!events || !events.length) {
|
||
return '<div class="tl-empty">Нет событий</div>';
|
||
}
|
||
|
||
// Newest first
|
||
const sorted = [...events].reverse();
|
||
|
||
let html = '';
|
||
let lastDay = null;
|
||
|
||
for (const ev of sorted) {
|
||
const day = ev.date ? new Date(ev.date).toDateString() : null;
|
||
if (day && day !== lastDay) {
|
||
html += `<div class="tl-day">${esc(fmtDayLabel(ev.date))}</div>`;
|
||
lastDay = day;
|
||
}
|
||
|
||
if (ev.kind === 'comment') {
|
||
const privClass = ev.private ? ' private' : '';
|
||
const lockIcon = ev.private ? ' 🔒' : '';
|
||
const initials = avatarInitials(ev.author);
|
||
const label = ev.private ? 'Приватный комментарий:' : 'Изменения:';
|
||
html += `<div class="ev-comment${privClass}">
|
||
<div class="ev-avatar">${esc(initials)}</div>
|
||
<div class="ev-comment-inner">
|
||
<div class="ev-comment-header">
|
||
${ev.date ? `<span class="ev-comment-time">${esc(fmtDateShort(ev.date))}</span>` : ''}
|
||
<span class="ev-comment-author">${esc(ev.author || 'Система')}${lockIcon}</span>
|
||
</div>
|
||
<div class="ev-comment-label">${label}</div>
|
||
<div class="ev-comment-bubble">
|
||
<div class="ev-comment-text">${esc(ev.text.replace(/<[^>]+>/g, ''))}</div>
|
||
</div>
|
||
</div>
|
||
</div>`;
|
||
} else if (ev.kind === 'file') {
|
||
const img = isImage(ev.contentType, ev.filename);
|
||
const fid = ev.fileId || null;
|
||
const privBadge = ev.private ? ' 🔒' : '';
|
||
if (fid && img) {
|
||
html += `<div class="ev-file-thumb" onclick="openLightbox('/api/files/${ev.fileId}', '${ev.filename.replace(/'/g,"\\'")}')">
|
||
<img src="/api/files/${ev.fileId}?thumb=1" alt="${esc(ev.filename)}" loading="lazy" onerror="imgError(this)">
|
||
<div class="ev-file-thumb-info">
|
||
<span class="ev-file-author">${esc(ev.author || '')}${privBadge}</span>
|
||
<span class="ev-file-name">${esc(ev.filename)}</span>
|
||
${ev.date ? `<span class="ev-file-time">${esc(fmtDateShort(ev.date))}</span>` : ''}
|
||
</div>
|
||
</div>`;
|
||
} else {
|
||
const icon = fileIcon(ev.filename);
|
||
const clickAttr = fid ? `onclick="openLightbox('/api/files/${ev.fileId}', '${ev.filename.replace(/'/g,"\\'")}', false)"` : '';
|
||
html += `<div class="ev-file" ${clickAttr} style="${fid ? 'cursor:pointer' : ''}">
|
||
<span class="ev-file-icon">${icon}</span>
|
||
<div style="flex:1;min-width:0">
|
||
<div style="font-size:13px;font-weight:500;overflow:hidden;text-overflow:ellipsis;white-space:nowrap">${esc(ev.filename)}${privBadge}</div>
|
||
<div style="font-size:12px;color:var(--hint)">${esc(ev.author || '')}${ev.date ? ' · ' + esc(fmtDateShort(ev.date)) : ''}</div>
|
||
</div>
|
||
</div>`;
|
||
}
|
||
} else {
|
||
// change
|
||
html += `<div class="ev-change">
|
||
<div class="ev-dot"></div>
|
||
<div class="ev-change-body">
|
||
<div class="ev-change-text">${formatChangeText(ev.text)}</div>
|
||
${ev.date ? `<div class="ev-time">${esc(fmtDateShort(ev.date))}</div>` : ''}
|
||
</div>
|
||
</div>`;
|
||
}
|
||
}
|
||
|
||
return `<div class="timeline">${html}</div>`;
|
||
}
|
||
|
||
function formatChangeText(text) {
|
||
// "Field: old → new" — make arrow and new value styled
|
||
const arrowIdx = text.indexOf(' → ');
|
||
if (arrowIdx !== -1) {
|
||
const before = text.slice(0, arrowIdx);
|
||
const after = text.slice(arrowIdx + 3);
|
||
return `${esc(before)} <span class="arrow">→</span> <span class="new-val">${esc(after)}</span>`;
|
||
}
|
||
return esc(text);
|
||
}
|
||
|
||
function resolveField(task, field) {
|
||
const v = task[field];
|
||
if (v === null || v === undefined) return null;
|
||
if (typeof v === 'object') return v.name || v.Name || v.Value || null;
|
||
return String(v) || null;
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// BUILD PAGE
|
||
// ═══════════════════════════════════════
|
||
function parseTaskUsers(task) {
|
||
// Parse taskusers JSON field - extract initiator(10), responsible(20), executor(30/50)
|
||
let initiator = null, executors = [], group = null, object = null;
|
||
try {
|
||
const tu = typeof task.taskusers === 'string' ? JSON.parse(task.taskusers) : task.taskusers;
|
||
if (tu && tu.TaskUsers) {
|
||
for (const u of tu.TaskUsers) {
|
||
if (u.Type === 10) initiator = u.Name || null;
|
||
if (u.Type === 30 || u.Type === 50) {
|
||
const n = (u.Name || '').trim();
|
||
if (n && !executors.includes(n)) executors.push(n);
|
||
}
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
try {
|
||
const tg = typeof task.taskusergroups === 'string' ? JSON.parse(task.taskusergroups) : task.taskusergroups;
|
||
if (tg && tg.TaskUserGroups) {
|
||
for (const g of tg.TaskUserGroups) {
|
||
if (g.Type === 10) group = g.Name || null;
|
||
if (g.Type === 50) object = g.Name || null;
|
||
}
|
||
}
|
||
} catch(e) {}
|
||
return { initiator, executors, group, object };
|
||
}
|
||
|
||
function shortenNameJS(name) {
|
||
if (!name) return name;
|
||
const parts = name.trim().split(/\s+/);
|
||
if (parts.length >= 2) return `${parts[0]} ${parts[1][0]}.`;
|
||
return name;
|
||
}
|
||
|
||
function buildPage(task, events, attachments) {
|
||
const num = task.tasknumber || task.Number || '?';
|
||
const name = task.name || task.Name || '—';
|
||
const statusId = typeof task.status === 'object' ? (task.status?.Id || task.status) : task.status;
|
||
const priorityId = typeof task.priority === 'object' ? (task.priority?.Id || task.priority) : task.priority;
|
||
const priorityName = task.priorityname || task.priorityName || null;
|
||
|
||
// Parse nested user/group fields
|
||
const parsed = parseTaskUsers(task);
|
||
const executor = parsed.executors.length ? parsed.executors.map(shortenNameJS).join(', ') : null;
|
||
const execGroup = parsed.group || null;
|
||
const initiator = parsed.initiator ? shortenNameJS(parsed.initiator) : null;
|
||
const objectName = parsed.object || null;
|
||
const serviceName = task.servicename || task.service || null;
|
||
const taskType = task.tasktypename || task.tasktype || null;
|
||
const createdAt = fmtDate(task.createdat);
|
||
const updatedAt = fmtDate(task.updatedat);
|
||
const deadlineAt = fmtDate(task.resolutiondateplan);
|
||
const closedAt = fmtDate(task.closedat);
|
||
const desc = (task.description || '').replace(/<[^>]+>/g, ' ').replace(/\s+/g, ' ').trim();
|
||
|
||
const taskUrl = `https://cleverparking.intradesk.ru/task/list/${num}`;
|
||
|
||
// Count comments
|
||
const commentCount = events.filter(e => e.kind === 'comment').length;
|
||
const eventCount = events.length;
|
||
|
||
const html = `
|
||
<!-- Hero -->
|
||
<div class="hero">
|
||
<div class="hero-meta">
|
||
<span class="task-num">#${esc(String(num))}</span>
|
||
${renderStatusPill(statusId)}
|
||
${renderPriorityPill(priorityId, priorityName)}
|
||
</div>
|
||
<div class="hero-title">${esc(name)}</div>
|
||
</div>
|
||
|
||
<!-- Details -->
|
||
<div class="section">
|
||
<div class="section-title">Детали заявки</div>
|
||
${objectName ? renderInfoRow('Объект', esc(objectName)) : ''}
|
||
${renderInfoRow('Исполнитель', executor ? esc(executor) : null)}
|
||
${renderInfoRow('Группа', execGroup ? esc(execGroup) : null)}
|
||
${renderInfoRow('Автор', initiator ? esc(initiator) : null)}
|
||
${renderInfoRow('Сервис', serviceName ? esc(serviceName) : null)}
|
||
${renderInfoRow('Тип', taskType ? esc(taskType) : null)}
|
||
${renderInfoRow('Создана', createdAt ? esc(createdAt) : null)}
|
||
${renderInfoRow('Обновлена', updatedAt ? esc(updatedAt) : null)}
|
||
${deadlineAt ? renderInfoRow('Плановый срок', `<span style="color:var(--hint)">${esc(deadlineAt)}</span>`) : ''}
|
||
${closedAt ? renderInfoRow('Закрыта', `<span style="color:var(--hint)">${esc(closedAt)}</span>`) : ''}
|
||
${renderInfoRow('Открыть', `<a href="${esc(taskUrl)}" target="_blank">cleverparking.intradesk.ru →</a>`)}
|
||
</div>
|
||
|
||
<!-- Description -->
|
||
${desc ? `
|
||
<div class="section">
|
||
<div class="section-title">Описание</div>
|
||
<div class="desc-body" id="desc-body">
|
||
${esc(desc)}
|
||
<div class="desc-fade"></div>
|
||
</div>
|
||
${desc.length > 200 ? `<button class="desc-toggle" onclick="toggleDesc()">Показать полностью</button>` : ''}
|
||
</div>` : ''}
|
||
|
||
<!-- Attachments -->
|
||
<div class="section">
|
||
<div class="section-title">Вложения${attachments.length ? ` (${attachments.length})` : ''}</div>
|
||
${renderAttachments(attachments)}
|
||
</div>
|
||
|
||
<!-- Timeline -->
|
||
<div class="section">
|
||
<div class="section-title">История${eventCount ? ` · ${eventCount} событий` : ''}${commentCount ? `, ${commentCount} комментариев` : ''}</div>
|
||
${renderTimeline(events)}
|
||
</div>
|
||
|
||
<div style="height:24px"></div>
|
||
`;
|
||
|
||
document.getElementById('content').innerHTML = html;
|
||
document.getElementById('loader').style.display = 'none';
|
||
document.getElementById('content').style.display = 'block';
|
||
|
||
// Check if description needs expand button
|
||
const descEl = document.getElementById('desc-body');
|
||
if (descEl && descEl.scrollHeight <= descEl.clientHeight + 10) {
|
||
const btn = descEl.nextElementSibling;
|
||
if (btn && btn.classList.contains('desc-toggle')) btn.style.display = 'none';
|
||
const fade = descEl.querySelector('.desc-fade');
|
||
if (fade) fade.remove();
|
||
}
|
||
}
|
||
|
||
function toggleDesc() {
|
||
const el = document.getElementById('desc-body');
|
||
const btn = el?.nextElementSibling;
|
||
if (!el) return;
|
||
el.classList.toggle('expanded');
|
||
if (btn) btn.textContent = el.classList.contains('expanded') ? 'Свернуть' : 'Показать полностью';
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// LOAD DATA
|
||
// ═══════════════════════════════════════
|
||
const CACHE_TTL = 5 * 60 * 1000; // 5 минут
|
||
|
||
function cacheGet(key) {
|
||
try {
|
||
const raw = sessionStorage.getItem(key);
|
||
if (!raw) return null;
|
||
const { ts, data } = JSON.parse(raw);
|
||
if (Date.now() - ts > CACHE_TTL) { sessionStorage.removeItem(key); return null; }
|
||
return data;
|
||
} catch { return null; }
|
||
}
|
||
|
||
function cacheSet(key, data) {
|
||
try { sessionStorage.setItem(key, JSON.stringify({ ts: Date.now(), data })); } catch {}
|
||
}
|
||
|
||
async function loadTask(taskNumber) {
|
||
try {
|
||
const cacheKey = `task_${taskNumber}`;
|
||
const cached = cacheGet(cacheKey);
|
||
if (cached) {
|
||
buildPage(cached.task, cached.events, cached.attachments);
|
||
return;
|
||
}
|
||
|
||
// Step 1: get task
|
||
const taskResp = await apiFetch(`/api/tasks/search?number=${encodeURIComponent(taskNumber)}`);
|
||
const task = await taskResp.json();
|
||
if (task.error || !task.tasknumber) throw new Error(task.error || 'Заявка не найдена');
|
||
|
||
const taskId = task.id || task.Id;
|
||
|
||
// Step 2: get lifetime events
|
||
let rawEvents = [];
|
||
if (taskId) {
|
||
const ltResp = await apiFetch(`/api/task/lifetime?task_id=${taskId}&number=${encodeURIComponent(taskNumber)}&top=500`);
|
||
if (ltResp.ok) rawEvents = await ltResp.json();
|
||
}
|
||
|
||
// Flatten nested structure: entry -> events.data[] -> sub-events
|
||
const flatEvents = [];
|
||
for (const entry of rawEvents) {
|
||
const username = entry.username || '';
|
||
const date = entry.eventat || entry.createdat || '';
|
||
const lifetimetype = entry.lifetimetype;
|
||
const subEvents = (entry.events && entry.events.data) || [];
|
||
for (const sub of subEvents) {
|
||
flatEvents.push(Object.assign({}, sub, { username, createdat: date, lifetimetype }));
|
||
}
|
||
}
|
||
|
||
// Parse events
|
||
const events = flatEvents
|
||
.map(parseEvent)
|
||
.filter(Boolean)
|
||
.sort((a, b) => {
|
||
if (!a.date && !b.date) return 0;
|
||
if (!a.date) return 1;
|
||
if (!b.date) return -1;
|
||
return new Date(a.date) - new Date(b.date);
|
||
});
|
||
|
||
// Collect file attachments from task object
|
||
const attachments = [];
|
||
const seen = new Set();
|
||
const addAttach = (arr) => {
|
||
if (!Array.isArray(arr)) return;
|
||
for (const f of arr) {
|
||
const name = f.Name || f.name || f.filename || '';
|
||
const fileId = f.Id || f.id || null;
|
||
const contentType = f.ContentType || f.contentType || '';
|
||
if (name && !seen.has(name)) {
|
||
seen.add(name);
|
||
attachments.push({ name, uploader: f.UploadedBy || f.uploadedBy || '', fileId, contentType });
|
||
}
|
||
}
|
||
};
|
||
addAttach(task.attachments);
|
||
addAttach(task.privateattachments);
|
||
|
||
for (const ev of events) {
|
||
if (ev.kind === 'file' && ev.filename && !seen.has(ev.filename)) {
|
||
seen.add(ev.filename);
|
||
attachments.push({ name: ev.filename, uploader: ev.uploader || '', fileId: ev.fileId || null, contentType: ev.contentType || '' });
|
||
}
|
||
}
|
||
|
||
cacheSet(cacheKey, { task, events, attachments });
|
||
buildPage(task, events, attachments);
|
||
|
||
} catch (err) {
|
||
document.getElementById('loader').style.display = 'none';
|
||
document.getElementById('err-msg').textContent = err.message || 'Ошибка загрузки';
|
||
document.getElementById('error-state').style.display = 'block';
|
||
}
|
||
}
|
||
|
||
// ═══════════════════════════════════════
|
||
// INIT
|
||
// ═══════════════════════════════════════
|
||
const params = new URLSearchParams(window.location.search);
|
||
const taskNum = params.get('task');
|
||
if (taskNum) {
|
||
loadTask(taskNum);
|
||
} else {
|
||
document.getElementById('loader').style.display = 'none';
|
||
document.getElementById('err-msg').textContent = 'Номер заявки не указан';
|
||
document.getElementById('error-state').style.display = 'block';
|
||
}
|
||
</script>
|
||
|
||
<!-- Lightbox -->
|
||
<div id="lightbox" onclick="if(event.target===this)closeLightbox()">
|
||
<button id="lightbox-close" onclick="closeLightbox()">✕</button>
|
||
<div id="lightbox-spinner"></div>
|
||
<img id="lightbox-img" style="display:none" alt="">
|
||
<div id="lightbox-name"></div>
|
||
<a id="lightbox-download" style="display:none" target="_blank">⬇ Открыть оригинал</a>
|
||
</div>
|
||
|
||
<script>
|
||
function openLightbox(url, name, isImg) {
|
||
const lb = document.getElementById('lightbox');
|
||
const img = document.getElementById('lightbox-img');
|
||
const spin = document.getElementById('lightbox-spinner');
|
||
const nameEl = document.getElementById('lightbox-name');
|
||
const dlBtn = document.getElementById('lightbox-download');
|
||
|
||
lb.classList.add('open');
|
||
nameEl.textContent = name || '';
|
||
|
||
if (isImg === false) {
|
||
// Non-image file — just show download link
|
||
spin.style.display = 'none';
|
||
img.style.display = 'none';
|
||
dlBtn.style.display = 'inline-block';
|
||
dlBtn.href = url;
|
||
return;
|
||
}
|
||
|
||
spin.style.display = 'block';
|
||
img.style.display = 'none';
|
||
dlBtn.style.display = 'none';
|
||
|
||
img.onload = () => {
|
||
spin.style.display = 'none';
|
||
img.style.display = 'block';
|
||
dlBtn.style.display = 'inline-block';
|
||
dlBtn.href = url;
|
||
};
|
||
img.onerror = () => {
|
||
spin.style.display = 'none';
|
||
nameEl.textContent = '⚠️ Не удалось загрузить файл: ' + (name || '');
|
||
dlBtn.style.display = 'inline-block';
|
||
dlBtn.href = url;
|
||
};
|
||
img.src = url;
|
||
}
|
||
|
||
function closeLightbox() {
|
||
const lb = document.getElementById('lightbox');
|
||
const img = document.getElementById('lightbox-img');
|
||
lb.classList.remove('open');
|
||
img.src = '';
|
||
}
|
||
|
||
// Close on back button (Telegram)
|
||
if (window.Telegram?.WebApp?.BackButton) {
|
||
const origOnClick = window.Telegram.WebApp.BackButton.onClick;
|
||
window.Telegram.WebApp.BackButton.onClick(() => {
|
||
const lb = document.getElementById('lightbox');
|
||
if (lb.classList.contains('open')) {
|
||
closeLightbox();
|
||
} else {
|
||
window.Telegram.WebApp.close();
|
||
}
|
||
});
|
||
}
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|