intrabot/web/templates/miniapp.html
2026-03-15 20:34:12 +03:00

1211 lines
38 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

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

<!DOCTYPE html>
<html lang="ru">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, 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,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
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>