|
|
|
@@ -0,0 +1,496 @@
|
|
|
|
|
<!doctype html>
|
|
|
|
|
<html lang="ru">
|
|
|
|
|
<head>
|
|
|
|
|
<meta charset="utf-8">
|
|
|
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
|
|
|
<style>
|
|
|
|
|
:root {
|
|
|
|
|
color-scheme: light dark;
|
|
|
|
|
font-family: ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif;
|
|
|
|
|
--bg: #ffffff;
|
|
|
|
|
--surface: #f6f8fb;
|
|
|
|
|
--surface-hover: #eef3f9;
|
|
|
|
|
--border: #d9e1ec;
|
|
|
|
|
--text: #152235;
|
|
|
|
|
--muted: #63728a;
|
|
|
|
|
--accent: #1769e0;
|
|
|
|
|
--accent-soft: #e7f0ff;
|
|
|
|
|
--success: #16794b;
|
|
|
|
|
--success-soft: #e6f7ef;
|
|
|
|
|
--warning: #9a6100;
|
|
|
|
|
--warning-soft: #fff4d8;
|
|
|
|
|
--danger: #b42318;
|
|
|
|
|
--danger-soft: #ffebe9;
|
|
|
|
|
--shadow: 0 8px 24px rgba(25, 55, 95, .08);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
@media (prefers-color-scheme: dark) {
|
|
|
|
|
:root {
|
|
|
|
|
--bg: #172131;
|
|
|
|
|
--surface: #202c3e;
|
|
|
|
|
--surface-hover: #29384c;
|
|
|
|
|
--border: #39495f;
|
|
|
|
|
--text: #eef4fc;
|
|
|
|
|
--muted: #a8b6c9;
|
|
|
|
|
--accent: #75a9ff;
|
|
|
|
|
--accent-soft: #263f68;
|
|
|
|
|
--success: #70d4a4;
|
|
|
|
|
--success-soft: #173d31;
|
|
|
|
|
--warning: #f4c467;
|
|
|
|
|
--warning-soft: #4b3918;
|
|
|
|
|
--danger: #ff9b91;
|
|
|
|
|
--danger-soft: #512522;
|
|
|
|
|
--shadow: 0 8px 24px rgba(0, 0, 0, .22);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
* { box-sizing: border-box; }
|
|
|
|
|
body { margin: 0; background: var(--bg); color: var(--text); }
|
|
|
|
|
button, input { font: inherit; }
|
|
|
|
|
button { color: inherit; }
|
|
|
|
|
.app { padding: 16px; max-width: 900px; margin: 0 auto; }
|
|
|
|
|
.header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
|
|
|
|
h1 { margin: 0; font-size: 20px; line-height: 1.25; letter-spacing: -.01em; }
|
|
|
|
|
.subtitle { margin: 5px 0 0; color: var(--muted); font-size: 13px; }
|
|
|
|
|
.toolbar { display: flex; align-items: center; gap: 8px; margin-top: 16px; }
|
|
|
|
|
.search { flex: 1; min-width: 0; border: 1px solid var(--border); border-radius: 9px; padding: 9px 11px; background: var(--bg); color: var(--text); outline: none; }
|
|
|
|
|
.search:focus { border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-soft); }
|
|
|
|
|
.button { border: 1px solid var(--border); border-radius: 9px; padding: 8px 11px; background: var(--surface); cursor: pointer; white-space: nowrap; }
|
|
|
|
|
.button:hover { background: var(--surface-hover); }
|
|
|
|
|
.button:focus-visible, .project-row:focus-visible { outline: 3px solid var(--accent-soft); outline-offset: 2px; }
|
|
|
|
|
.button:disabled { cursor: wait; opacity: .62; }
|
|
|
|
|
.summary { display: flex; flex-wrap: wrap; gap: 8px; margin: 14px 0; }
|
|
|
|
|
.summary-item { border-radius: 999px; padding: 5px 9px; background: var(--surface); color: var(--muted); font-size: 12px; }
|
|
|
|
|
.summary-item strong { color: var(--text); font-weight: 650; }
|
|
|
|
|
.table { border: 1px solid var(--border); border-radius: 11px; overflow: hidden; box-shadow: var(--shadow); }
|
|
|
|
|
.table-head, .project-row { display: grid; grid-template-columns: minmax(0, 1.7fr) minmax(130px, .8fr) minmax(110px, .7fr); gap: 12px; align-items: center; }
|
|
|
|
|
.table-head { padding: 9px 12px; background: var(--surface); color: var(--muted); font-size: 11px; font-weight: 650; text-transform: uppercase; letter-spacing: .04em; }
|
|
|
|
|
.project-row { width: 100%; border: 0; border-top: 1px solid var(--border); padding: 12px; background: var(--bg); text-align: left; cursor: pointer; }
|
|
|
|
|
.project-row:hover { background: var(--surface-hover); }
|
|
|
|
|
.project-name { min-width: 0; }
|
|
|
|
|
.project-title { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-weight: 620; }
|
|
|
|
|
.project-code { display: block; margin-top: 3px; color: var(--muted); font-size: 12px; }
|
|
|
|
|
.deadline { color: var(--text); font-size: 13px; }
|
|
|
|
|
.deadline.missing { color: var(--muted); }
|
|
|
|
|
.deadline.overdue { color: var(--danger); font-weight: 620; }
|
|
|
|
|
.status { display: inline-flex; width: fit-content; max-width: 100%; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; border-radius: 999px; padding: 5px 9px; font-size: 12px; font-weight: 620; }
|
|
|
|
|
.status-neutral { background: var(--surface); color: var(--muted); }
|
|
|
|
|
.status-progress { background: var(--accent-soft); color: var(--accent); }
|
|
|
|
|
.status-done { background: var(--success-soft); color: var(--success); }
|
|
|
|
|
.status-warning { background: var(--warning-soft); color: var(--warning); }
|
|
|
|
|
.status-danger { background: var(--danger-soft); color: var(--danger); }
|
|
|
|
|
.empty, .error { padding: 22px 14px; color: var(--muted); text-align: center; }
|
|
|
|
|
.error { color: var(--danger); }
|
|
|
|
|
.details { margin-top: 14px; padding: 14px; border: 1px solid var(--border); border-radius: 11px; background: var(--surface); }
|
|
|
|
|
.details[hidden], .error[hidden] { display: none; }
|
|
|
|
|
.details-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; }
|
|
|
|
|
.details-title { margin: 0; font-size: 16px; }
|
|
|
|
|
.details-code { margin: 3px 0 0; color: var(--muted); font-size: 12px; }
|
|
|
|
|
.details-grid { display: grid; grid-template-columns: repeat(3, minmax(0, 1fr)); gap: 10px; margin: 14px 0 0; }
|
|
|
|
|
.detail-label { display: block; margin-bottom: 3px; color: var(--muted); font-size: 11px; }
|
|
|
|
|
.detail-value { display: block; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 13px; }
|
|
|
|
|
.loading { color: var(--muted); font-size: 13px; }
|
|
|
|
|
@media (max-width: 600px) {
|
|
|
|
|
.app { padding: 12px; }
|
|
|
|
|
.header { display: block; }
|
|
|
|
|
.header .button { margin-top: 10px; }
|
|
|
|
|
.table-head, .project-row { grid-template-columns: minmax(0, 1.4fr) minmax(100px, .8fr) minmax(90px, .7fr); gap: 8px; }
|
|
|
|
|
.table-head, .project-row { padding-left: 9px; padding-right: 9px; }
|
|
|
|
|
.details-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
|
|
|
|
}
|
|
|
|
|
</style>
|
|
|
|
|
</head>
|
|
|
|
|
<body>
|
|
|
|
|
<main class="app" aria-live="polite">
|
|
|
|
|
<header class="header">
|
|
|
|
|
<div>
|
|
|
|
|
<h1 data-i18n="title">Активные проекты</h1>
|
|
|
|
|
<p class="subtitle" data-i18n="subtitle">Сроки завершения и текущие статусы</p>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="button" id="refresh" type="button" data-i18n="refresh">Обновить</button>
|
|
|
|
|
</header>
|
|
|
|
|
<div class="toolbar">
|
|
|
|
|
<input class="search" id="search" type="search" autocomplete="off" data-i18n-placeholder="search" placeholder="Поиск по проекту или коду">
|
|
|
|
|
</div>
|
|
|
|
|
<div class="summary" id="summary"></div>
|
|
|
|
|
<section class="table" aria-label="Проекты">
|
|
|
|
|
<div class="table-head">
|
|
|
|
|
<span data-i18n="project">Проект</span>
|
|
|
|
|
<span data-i18n="deadline">Дата завершения</span>
|
|
|
|
|
<span data-i18n="status">Статус</span>
|
|
|
|
|
</div>
|
|
|
|
|
<div id="rows"></div>
|
|
|
|
|
<div class="empty" id="empty" hidden></div>
|
|
|
|
|
<div class="error" id="error" hidden></div>
|
|
|
|
|
</section>
|
|
|
|
|
<section class="details" id="details" hidden>
|
|
|
|
|
<div class="details-header">
|
|
|
|
|
<div>
|
|
|
|
|
<h2 class="details-title" id="details-title"></h2>
|
|
|
|
|
<p class="details-code" id="details-code"></p>
|
|
|
|
|
</div>
|
|
|
|
|
<button class="button" id="close-details" type="button" data-i18n="close">Закрыть</button>
|
|
|
|
|
</div>
|
|
|
|
|
<div class="details-grid" id="details-grid"></div>
|
|
|
|
|
<p class="loading" id="details-loading" hidden></p>
|
|
|
|
|
</section>
|
|
|
|
|
</main>
|
|
|
|
|
<script>
|
|
|
|
|
(function () {
|
|
|
|
|
'use strict';
|
|
|
|
|
|
|
|
|
|
var messages = {
|
|
|
|
|
ru: {
|
|
|
|
|
title: 'Активные проекты',
|
|
|
|
|
subtitle: 'Сроки завершения и текущие статусы',
|
|
|
|
|
refresh: 'Обновить',
|
|
|
|
|
search: 'Поиск по проекту или коду',
|
|
|
|
|
project: 'Проект',
|
|
|
|
|
deadline: 'Дата завершения',
|
|
|
|
|
status: 'Статус',
|
|
|
|
|
close: 'Закрыть',
|
|
|
|
|
missingDate: 'Не указана',
|
|
|
|
|
noProjects: 'Проекты не найдены.',
|
|
|
|
|
noMatch: 'По фильтру ничего не найдено.',
|
|
|
|
|
loading: 'Загрузка…',
|
|
|
|
|
loadingProject: 'Загрузка данных проекта…',
|
|
|
|
|
refreshError: 'Не удалось обновить список проектов.',
|
|
|
|
|
projectError: 'Не удалось получить данные проекта.',
|
|
|
|
|
total: 'Всего',
|
|
|
|
|
withoutDate: 'Без даты',
|
|
|
|
|
archived: 'Архивных',
|
|
|
|
|
phase: 'Этап',
|
|
|
|
|
timezone: 'Часовой пояс',
|
|
|
|
|
updated: 'Обновлён',
|
|
|
|
|
code: 'Код'
|
|
|
|
|
},
|
|
|
|
|
en: {
|
|
|
|
|
title: 'Active projects',
|
|
|
|
|
subtitle: 'Completion dates and current statuses',
|
|
|
|
|
refresh: 'Refresh',
|
|
|
|
|
search: 'Search by project or code',
|
|
|
|
|
project: 'Project',
|
|
|
|
|
deadline: 'Completion date',
|
|
|
|
|
status: 'Status',
|
|
|
|
|
close: 'Close',
|
|
|
|
|
missingDate: 'Not set',
|
|
|
|
|
noProjects: 'No projects found.',
|
|
|
|
|
noMatch: 'No projects match the filter.',
|
|
|
|
|
loading: 'Loading…',
|
|
|
|
|
loadingProject: 'Loading project data…',
|
|
|
|
|
refreshError: 'Could not refresh the project list.',
|
|
|
|
|
projectError: 'Could not load the project data.',
|
|
|
|
|
total: 'Total',
|
|
|
|
|
withoutDate: 'No date',
|
|
|
|
|
archived: 'Archived',
|
|
|
|
|
phase: 'Phase',
|
|
|
|
|
timezone: 'Timezone',
|
|
|
|
|
updated: 'Updated',
|
|
|
|
|
code: 'Code'
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
var locale = (document.documentElement.lang || 'ru').toLowerCase().indexOf('ru') === 0 ? 'ru' : 'en';
|
|
|
|
|
var t = messages[locale];
|
|
|
|
|
var currentSnapshot = null;
|
|
|
|
|
var currentProjects = [];
|
|
|
|
|
var latestToolInput = null;
|
|
|
|
|
var pendingRequests = new Map();
|
|
|
|
|
var nextRequestId = 1;
|
|
|
|
|
var mcpAppsProtocolVersion = '2026-01-26';
|
|
|
|
|
var busy = false;
|
|
|
|
|
|
|
|
|
|
var elements = {
|
|
|
|
|
refresh: document.getElementById('refresh'),
|
|
|
|
|
search: document.getElementById('search'),
|
|
|
|
|
summary: document.getElementById('summary'),
|
|
|
|
|
rows: document.getElementById('rows'),
|
|
|
|
|
empty: document.getElementById('empty'),
|
|
|
|
|
error: document.getElementById('error'),
|
|
|
|
|
details: document.getElementById('details'),
|
|
|
|
|
detailsTitle: document.getElementById('details-title'),
|
|
|
|
|
detailsCode: document.getElementById('details-code'),
|
|
|
|
|
detailsGrid: document.getElementById('details-grid'),
|
|
|
|
|
detailsLoading: document.getElementById('details-loading'),
|
|
|
|
|
closeDetails: document.getElementById('close-details')
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
function applyTranslations() {
|
|
|
|
|
document.querySelectorAll('[data-i18n]').forEach(function (node) {
|
|
|
|
|
var key = node.getAttribute('data-i18n');
|
|
|
|
|
if (t[key]) node.textContent = t[key];
|
|
|
|
|
});
|
|
|
|
|
document.querySelectorAll('[data-i18n-placeholder]').forEach(function (node) {
|
|
|
|
|
var key = node.getAttribute('data-i18n-placeholder');
|
|
|
|
|
if (t[key]) node.setAttribute('placeholder', t[key]);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function request(method, params) {
|
|
|
|
|
var openai = typeof window !== 'undefined' ? window.openai : undefined;
|
|
|
|
|
if (method === 'tools/call' && openai && typeof openai.callTool === 'function') {
|
|
|
|
|
return openai.callTool(params.name, params.arguments || {});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
var id = nextRequestId++;
|
|
|
|
|
window.parent.postMessage({ jsonrpc: '2.0', id: id, method: method, params: params }, '*');
|
|
|
|
|
return new Promise(function (resolve, reject) {
|
|
|
|
|
var timeout = window.setTimeout(function () {
|
|
|
|
|
pendingRequests.delete(id);
|
|
|
|
|
reject(new Error('Host request timed out'));
|
|
|
|
|
}, 10000);
|
|
|
|
|
pendingRequests.set(id, { resolve: resolve, reject: reject, timeout: timeout });
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function notify(method, params) {
|
|
|
|
|
window.parent.postMessage({ jsonrpc: '2.0', method: method, params: params || {} }, '*');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function initializeHost() {
|
|
|
|
|
return request('ui/initialize', {
|
|
|
|
|
protocolVersion: mcpAppsProtocolVersion,
|
|
|
|
|
appInfo: {
|
|
|
|
|
name: 'SHD Active Projects',
|
|
|
|
|
title: 'SHD Active Projects',
|
|
|
|
|
version: '1.0.0',
|
|
|
|
|
websiteUrl: 'https://shd.xyz.su'
|
|
|
|
|
},
|
|
|
|
|
appCapabilities: {}
|
|
|
|
|
}).then(function () {
|
|
|
|
|
notify('ui/notifications/initialized', {});
|
|
|
|
|
}).catch(function () {
|
|
|
|
|
// The window.openai compatibility bridge can work without the raw postMessage handshake.
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function normalizePayload(value) {
|
|
|
|
|
if (!value) return null;
|
|
|
|
|
if (value.structuredContent) return value.structuredContent;
|
|
|
|
|
if (value.toolOutput) return value.toolOutput;
|
|
|
|
|
return value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProjects(value) {
|
|
|
|
|
var payload = normalizePayload(value);
|
|
|
|
|
if (!payload || payload.error) return [];
|
|
|
|
|
if (Array.isArray(payload.projects)) return payload.projects.filter(function (item) { return item && typeof item === 'object'; });
|
|
|
|
|
var data = payload.data;
|
|
|
|
|
if (data && !Array.isArray(data) && Array.isArray(data.data)) data = data.data;
|
|
|
|
|
return Array.isArray(data) ? data.filter(function (item) { return item && typeof item === 'object'; }) : [];
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProjectCode(project) {
|
|
|
|
|
return String(project.code || project.project_code || project.contract_project_id || project.id || '');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getProjectName(project) {
|
|
|
|
|
return String(project.display_name || project.title || project.name || getProjectCode(project) || '—');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getStatus(project) {
|
|
|
|
|
return String(project.status || project.phase || '—');
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getTone(status) {
|
|
|
|
|
var value = String(status).toLowerCase();
|
|
|
|
|
if (/done|complete|completed|closed|resolved|finished|готов|заверш/.test(value)) return 'done';
|
|
|
|
|
if (/cancel|reject|blocked|error|отмен|отклон|блок/.test(value)) return 'danger';
|
|
|
|
|
if (/progress|active|work|processing|в работе|актив/.test(value)) return 'progress';
|
|
|
|
|
if (/pending|review|pause|ожид|провер|пауза/.test(value)) return 'warning';
|
|
|
|
|
return 'neutral';
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function getDeadlineTime(project) {
|
|
|
|
|
var value = project.deadline;
|
|
|
|
|
if (!value) return Number.POSITIVE_INFINITY;
|
|
|
|
|
var timestamp = Date.parse(String(value));
|
|
|
|
|
return Number.isNaN(timestamp) ? Number.POSITIVE_INFINITY : timestamp;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function formatDate(value) {
|
|
|
|
|
if (!value) return t.missingDate;
|
|
|
|
|
var timestamp = Date.parse(String(value));
|
|
|
|
|
if (Number.isNaN(timestamp)) return String(value);
|
|
|
|
|
try {
|
|
|
|
|
return new Intl.DateTimeFormat(locale, { day: '2-digit', month: '2-digit', year: 'numeric' }).format(new Date(timestamp));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
return String(value);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function isOverdue(project) {
|
|
|
|
|
var timestamp = getDeadlineTime(project);
|
|
|
|
|
var status = getStatus(project).toLowerCase();
|
|
|
|
|
return Number.isFinite(timestamp) && timestamp < Date.now() && !/done|complete|closed|resolved|заверш/.test(status);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function makeElement(tag, text, className) {
|
|
|
|
|
var node = document.createElement(tag);
|
|
|
|
|
if (className) node.className = className;
|
|
|
|
|
if (text !== undefined) node.textContent = text;
|
|
|
|
|
return node;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderSummary(projects) {
|
|
|
|
|
elements.summary.replaceChildren();
|
|
|
|
|
var noDate = projects.filter(function (project) { return !project.deadline; }).length;
|
|
|
|
|
var archived = projects.filter(function (project) { return project.is_archived === true; }).length;
|
|
|
|
|
var total = makeElement('span', '', 'summary-item');
|
|
|
|
|
total.append(makeElement('strong', String(projects.length)), ' ' + t.total);
|
|
|
|
|
elements.summary.append(total);
|
|
|
|
|
if (noDate) {
|
|
|
|
|
var missing = makeElement('span', '', 'summary-item');
|
|
|
|
|
missing.append(makeElement('strong', String(noDate)), ' ' + t.withoutDate);
|
|
|
|
|
elements.summary.append(missing);
|
|
|
|
|
}
|
|
|
|
|
if (archived) {
|
|
|
|
|
var archivedNode = makeElement('span', '', 'summary-item');
|
|
|
|
|
archivedNode.append(makeElement('strong', String(archived)), ' ' + t.archived);
|
|
|
|
|
elements.summary.append(archivedNode);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function renderRows() {
|
|
|
|
|
var query = elements.search.value.trim().toLowerCase();
|
|
|
|
|
var projects = currentProjects.filter(function (project) {
|
|
|
|
|
if (!query) return true;
|
|
|
|
|
return (getProjectName(project) + ' ' + getProjectCode(project) + ' ' + getStatus(project)).toLowerCase().indexOf(query) !== -1;
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
elements.rows.replaceChildren();
|
|
|
|
|
elements.error.hidden = true;
|
|
|
|
|
elements.empty.hidden = projects.length !== 0;
|
|
|
|
|
elements.empty.textContent = currentProjects.length === 0 ? t.noProjects : t.noMatch;
|
|
|
|
|
projects.forEach(function (project) {
|
|
|
|
|
var row = makeElement('button', '', 'project-row');
|
|
|
|
|
row.type = 'button';
|
|
|
|
|
row.setAttribute('aria-label', getProjectName(project));
|
|
|
|
|
|
|
|
|
|
var name = makeElement('span', '', 'project-name');
|
|
|
|
|
name.append(makeElement('span', getProjectName(project), 'project-title'));
|
|
|
|
|
name.append(makeElement('span', getProjectCode(project), 'project-code'));
|
|
|
|
|
|
|
|
|
|
var deadline = makeElement('span', formatDate(project.deadline), 'deadline' + (project.deadline ? '' : ' missing') + (isOverdue(project) ? ' overdue' : ''));
|
|
|
|
|
var status = makeElement('span', getStatus(project), 'status status-' + getTone(getStatus(project)));
|
|
|
|
|
row.append(name, deadline, status);
|
|
|
|
|
row.addEventListener('click', function () { showProject(project); });
|
|
|
|
|
elements.rows.append(row);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function render(value) {
|
|
|
|
|
var payload = normalizePayload(value);
|
|
|
|
|
currentSnapshot = payload;
|
|
|
|
|
currentProjects = getProjects(payload).slice().sort(function (left, right) {
|
|
|
|
|
return getDeadlineTime(left) - getDeadlineTime(right) || getProjectName(left).localeCompare(getProjectName(right), locale);
|
|
|
|
|
});
|
|
|
|
|
renderSummary(currentProjects);
|
|
|
|
|
renderRows();
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setBusy(nextBusy, label) {
|
|
|
|
|
busy = nextBusy;
|
|
|
|
|
elements.refresh.disabled = nextBusy;
|
|
|
|
|
if (nextBusy) elements.refresh.textContent = label || t.loading;
|
|
|
|
|
else elements.refresh.textContent = t.refresh;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function setError(message) {
|
|
|
|
|
elements.error.hidden = false;
|
|
|
|
|
elements.error.textContent = message;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function unwrapResult(value) {
|
|
|
|
|
var payload = normalizePayload(value);
|
|
|
|
|
if (value && value.isError) throw new Error(payload && payload.error ? payload.error : t.refreshError);
|
|
|
|
|
if (payload && payload.error) throw new Error(String(payload.error));
|
|
|
|
|
return payload || value;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function refresh() {
|
|
|
|
|
if (busy) return;
|
|
|
|
|
setBusy(true);
|
|
|
|
|
elements.error.hidden = true;
|
|
|
|
|
try {
|
|
|
|
|
var result = await request('tools/call', { name: 'shd_list_projects', arguments: { archived: false, limit: 100 } });
|
|
|
|
|
render(unwrapResult(result));
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setError(error && error.message ? error.message : t.refreshError);
|
|
|
|
|
} finally {
|
|
|
|
|
setBusy(false);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function addDetail(label, value) {
|
|
|
|
|
var item = makeElement('div');
|
|
|
|
|
item.append(makeElement('span', label, 'detail-label'), makeElement('span', value || '—', 'detail-value'));
|
|
|
|
|
elements.detailsGrid.append(item);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
function showDetailSnapshot(project) {
|
|
|
|
|
elements.detailsTitle.textContent = getProjectName(project);
|
|
|
|
|
elements.detailsCode.textContent = getProjectCode(project) ? t.code + ': ' + getProjectCode(project) : '';
|
|
|
|
|
elements.detailsGrid.replaceChildren();
|
|
|
|
|
addDetail(t.deadline, formatDate(project.deadline));
|
|
|
|
|
addDetail(t.status, getStatus(project));
|
|
|
|
|
addDetail(t.phase, String(project.phase || '—'));
|
|
|
|
|
addDetail(t.timezone, String(project.timezone || '—'));
|
|
|
|
|
addDetail(t.updated, formatDate(project.updated_at));
|
|
|
|
|
elements.details.hidden = false;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
async function showProject(project) {
|
|
|
|
|
showDetailSnapshot(project);
|
|
|
|
|
var code = getProjectCode(project);
|
|
|
|
|
if (!code) return;
|
|
|
|
|
elements.detailsLoading.hidden = false;
|
|
|
|
|
elements.detailsLoading.textContent = t.loadingProject;
|
|
|
|
|
try {
|
|
|
|
|
var result = await request('tools/call', { name: 'shd_get_project', arguments: { project_code: code } });
|
|
|
|
|
var payload = unwrapResult(result);
|
|
|
|
|
var data = payload && payload.data;
|
|
|
|
|
if (Array.isArray(data)) data = data[0];
|
|
|
|
|
if (data && typeof data === 'object') showDetailSnapshot(data);
|
|
|
|
|
} catch (error) {
|
|
|
|
|
setError(error && error.message ? error.message : t.projectError);
|
|
|
|
|
} finally {
|
|
|
|
|
elements.detailsLoading.hidden = true;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
window.addEventListener('message', function (event) {
|
|
|
|
|
if (event.source !== window.parent) return;
|
|
|
|
|
var message = event.data;
|
|
|
|
|
if (!message || message.jsonrpc !== '2.0') return;
|
|
|
|
|
if (message.id !== undefined && pendingRequests.has(message.id)) {
|
|
|
|
|
var pending = pendingRequests.get(message.id);
|
|
|
|
|
pendingRequests.delete(message.id);
|
|
|
|
|
window.clearTimeout(pending.timeout);
|
|
|
|
|
if (message.error) pending.reject(new Error(message.error.message || t.refreshError));
|
|
|
|
|
else pending.resolve(message.result);
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
if (message.method === 'ui/notifications/tool-input') {
|
|
|
|
|
latestToolInput = message.params || null;
|
|
|
|
|
if (!currentProjects.length) render(latestToolInput);
|
|
|
|
|
}
|
|
|
|
|
if (message.method === 'ui/notifications/tool-result') render(message.params && message.params.structuredContent);
|
|
|
|
|
}, { passive: true });
|
|
|
|
|
|
|
|
|
|
elements.search.addEventListener('input', renderRows);
|
|
|
|
|
elements.refresh.addEventListener('click', refresh);
|
|
|
|
|
elements.closeDetails.addEventListener('click', function () { elements.details.hidden = true; });
|
|
|
|
|
applyTranslations();
|
|
|
|
|
void initializeHost();
|
|
|
|
|
|
|
|
|
|
var initialOutput = typeof window !== 'undefined' && window.openai ? window.openai.toolOutput : null;
|
|
|
|
|
if (initialOutput) render(initialOutput);
|
|
|
|
|
else {
|
|
|
|
|
latestToolInput = typeof window !== 'undefined' && window.openai ? window.openai.toolInput : null;
|
|
|
|
|
render(latestToolInput || currentSnapshot);
|
|
|
|
|
}
|
|
|
|
|
}());
|
|
|
|
|
</script>
|
|
|
|
|
</body>
|
|
|
|
|
</html>
|