@@ -0,0 +1,282 @@
<!doctype html>
< html lang = "ru" >
< head >
< meta charset = "utf-8" >
< meta name = "viewport" content = "width=device-width, initial-scale=1" >
< title > CRM — SHD MCP< / title >
< style >
: root { color-scheme : light dark ; --bg : #f7f8fa ; --panel : #fff ; --line : #d9dee7 ; --ink : #172033 ; --muted : #687386 ; --accent : #2563eb ; --accent-soft : #e8efff ; --danger : #b42318 }
* { box-sizing : border-box } body { margin : 0 ; background : var ( - - bg ) ; color : var ( - - ink ) ; font : 14 px / 1.45 system-ui , - apple-system , BlinkMacSystemFont , "Segoe UI" , sans-serif }
. shell { max-width : 980 px ; margin : 0 auto ; padding : 24 px } . eyebrow { color : var ( - - accent ) ; font-size : 11 px ; font-weight : 700 ; letter-spacing : .12 em ; text-transform : uppercase }
h1 { margin : 4 px 0 6 px ; font-size : 25 px ; line-height : 1.15 } . lede { margin : 0 ; color : var ( - - muted ) } . summary { display : flex ; gap : 10 px ; flex-wrap : wrap ; margin : 18 px 0 14 px }
. summary span { padding : 5 px 9 px ; border : 1 px solid var ( - - line ) ; border-radius : 999 px ; background : var ( - - panel ) ; color : var ( - - muted ) } . summary strong { color : var ( - - ink ) }
. toolbar { display : flex ; gap : 8 px ; margin-bottom : 12 px } . search { min-width : 0 ; flex : 1 ; padding : 9 px 11 px ; border : 1 px solid var ( - - line ) ; border-radius : 8 px ; background : var ( - - panel ) ; color : inherit }
button { cursor : pointer } . refresh { padding : 9 px 14 px ; border : 1 px solid var ( - - accent ) ; border-radius : 8 px ; background : var ( - - accent ) ; color : #fff ; font-weight : 600 } . refresh : disabled { opacity : .6 ; cursor : wait }
. status-message { min-height : 22 px ; color : var ( - - muted ) } . error { color : var ( - - danger ) } . table-wrap { overflow : auto ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; background : var ( - - panel ) }
table { width : 100 % ; border-collapse : collapse ; min-width : 560 px } th , td { padding : 11 px 13 px ; border-bottom : 1 px solid var ( - - line ) ; text-align : left ; vertical-align : top } th { color : var ( - - muted ) ; font-size : 12 px ; font-weight : 600 }
tbody tr : last-child td { border-bottom : 0 } . record { width : 100 % ; border : 0 ; background : transparent ; color : inherit ; text-align : left } . record : hover { background : var ( - - accent - soft ) }
. title { font-weight : 650 } . subtitle { display : block ; color : var ( - - muted ) ; font-size : 12 px ; margin-top : 2 px } . pill { display : inline-block ; padding : 3 px 8 px ; border-radius : 999 px ; background : var ( - - accent - soft ) ; color : var ( - - accent ) ; font-size : 12 px ; white-space : nowrap }
. empty { padding : 24 px ; text-align : center ; color : var ( - - muted ) } . details { margin-top : 14 px ; padding : 16 px ; border : 1 px solid var ( - - line ) ; border-radius : 10 px ; background : var ( - - panel ) } . details [ hidden ] { display : none }
. details-head { display : flex ; justify-content : space-between ; gap : 12 px ; align-items : start } . details h2 { margin : 0 ; font-size : 18 px } . close { border : 0 ; background : transparent ; color : var ( - - muted ) ; font-size : 20 px } . detail-grid { display : grid ; grid-template-columns : repeat ( auto - fit , minmax ( 150 px , 1 fr ) ) ; gap : 10 px ; margin-top : 14 px }
. detail { padding : 9 px ; border-radius : 8 px ; background : var ( - - bg ) } . detail-label { display : block ; color : var ( - - muted ) ; font-size : 11 px ; margin-bottom : 3 px } . detail-value { overflow-wrap : anywhere }
@ media ( prefers-color-scheme : dark ) { : root { --bg : #11151d ; --panel : #1b2230 ; --line : #303a4b ; --ink : #edf2fb ; --muted : #aab4c5 ; --accent : #82aaff ; --accent-soft : #263858 ; --danger : #ff9a93 } }
< / style >
< / head >
< body >
< main class = "shell" data-widget-module = "crm" >
< header >
< div class = "eyebrow" > SHD MCP< / div >
< h1 > CRM< / h1 >
< p class = "lede" > Реестр CRM-сущностей с поиском и просмотром деталей.< / p >
< div class = "summary" id = "summary" > < / div >
< / header >
< div class = "toolbar" >
< input class = "search" id = "search" type = "search" placeholder = "Поиск по названию, статусу или ID" autocomplete = "off" >
< button class = "refresh" id = "refresh" type = "button" > Обновить< / button >
< / div >
< div class = "status-message" id = "status" role = "status" aria-live = "polite" > < / div >
< div class = "table-wrap" >
< table >
< thead > < tr > < th > Сущность< / th > < th > Статус< / th > < th > Обновлено< / th > < th > Тип< / th > < / tr > < / thead >
< tbody id = "rows" > < / tbody >
< / table >
< div class = "empty" id = "empty" hidden > Нет данных для отображения.< / div >
< / div >
< section class = "details" id = "details" hidden aria-live = "polite" >
< div class = "details-head" > < div > < h2 id = "details-title" > < / h2 > < div class = "subtitle" id = "details-subtitle" > < / div > < / div > < button class = "close" id = "close" type = "button" aria-label = "Закрыть" > × < / button > < / div >
< div class = "detail-grid" id = "detail-grid" > < / div >
< / section >
< / main >
< script >
( function ( ) {
var CONFIG = { "module" : "crm" , "title" : "CRM" , "description" : "Реестр CRM-сущностей с поиском и просмотром деталей." , "nameLabel" : "Сущность" , "statusLabel" : "Статус" , "dateLabel" : "Обновлено" , "valueLabel" : "Тип" , "listTool" : "shd_list_crm" , "detailTool" : "shd_get_crm_entity" , "detailKey" : "entity_id" , "detailSection" : "deals" , "listArgs" : { "section" : "deals" , "archived" : false , "limit" : 100 } } ;
var pending = new Map ( ) ;
var nextRequestId = 1 ;
var records = [ ] ;
var busy = false ;
var elements = {
search : document . getElementById ( 'search' ) ,
refresh : document . getElementById ( 'refresh' ) ,
status : document . getElementById ( 'status' ) ,
summary : document . getElementById ( 'summary' ) ,
rows : document . getElementById ( 'rows' ) ,
empty : document . getElementById ( 'empty' ) ,
details : document . getElementById ( 'details' ) ,
detailsTitle : document . getElementById ( 'details-title' ) ,
detailsSubtitle : document . getElementById ( 'details-subtitle' ) ,
detailGrid : document . getElementById ( 'detail-grid' ) ,
close : document . getElementById ( 'close' )
} ;
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 ( ) {
pending . delete ( id ) ;
reject ( new Error ( 'Host request timed out' ) ) ;
} , 10000 ) ;
pending . set ( id , { resolve : resolve , reject : reject , timeout : timeout } ) ;
} ) ;
}
function notify ( method , params ) {
window . parent . postMessage ( { jsonrpc : '2.0' , method : method , params : params || { } } , '*' ) ;
}
function normalizePayload ( value ) {
if ( ! value ) return null ;
if ( value . structuredContent ) return value . structuredContent ;
if ( value . toolOutput ) return value . toolOutput ;
return value ;
}
function unwrap ( value ) {
var payload = normalizePayload ( value ) ;
if ( value && value . isError ) throw new Error ( 'MCP tool returned an error' ) ;
if ( payload && payload . error ) throw new Error ( String ( payload . error ) ) ;
return payload || value ;
}
function toArray ( value ) {
var payload = normalizePayload ( value ) ;
if ( ! payload ) return [ ] ;
var data = payload . data ;
if ( data && ! Array . isArray ( data ) && Array . isArray ( data . data ) ) data = data . data ;
if ( Array . isArray ( payload . items ) ) data = payload . items ;
return Array . isArray ( data ) ? data . filter ( function ( item ) { return item && typeof item === 'object' ; } ) : [ ] ;
}
function idOf ( item ) { return String ( item . id || item . code || item . key || item . number || '' ) ; }
function titleOf ( item ) { return String ( item . title || item . name || item . display _name || item . number || idOf ( item ) || '—' ) ; }
function subtitleOf ( item ) { return String ( item . subtitle || item . project _code || item . code || item . key || '' ) ; }
function statusOf ( item ) {
var value = item . status ;
if ( value && typeof value === 'object' ) value = value . name || value . id ;
return String ( value || item . phase || '—' ) ;
}
function dateOf ( item ) { return String ( item . date || item . due _date || item . updated _at || item . issued _at || item . created _at || '' ) ; }
function valueOf ( item ) { return item . value === null || item . value === undefined ? '' : String ( item . value ) ; }
function dateText ( value ) {
if ( ! value ) return '—' ;
var timestamp = Date . parse ( value ) ;
if ( Number . isNaN ( timestamp ) ) return value ;
try { return new Intl . DateTimeFormat ( undefined , { day : '2-digit' , month : '2-digit' , year : 'numeric' } ) . format ( new Date ( timestamp ) ) ; }
catch ( error ) { return value ; }
}
function make ( tag , value , className ) {
var node = document . createElement ( tag ) ;
if ( className ) node . className = className ;
if ( value !== undefined ) node . textContent = value ;
return node ;
}
function renderSummary ( ) {
elements . summary . replaceChildren ( ) ;
var item = make ( 'span' ) ;
item . append ( make ( 'strong' , String ( records . length ) ) , ' записей' ) ;
elements . summary . append ( item ) ;
}
function renderRows ( ) {
var query = elements . search . value . trim ( ) . toLowerCase ( ) ;
var filtered = records . filter ( function ( item ) {
if ( ! query ) return true ;
return ( titleOf ( item ) + ' ' + subtitleOf ( item ) + ' ' + statusOf ( item ) + ' ' + idOf ( item ) ) . toLowerCase ( ) . indexOf ( query ) !== - 1 ;
} ) ;
elements . rows . replaceChildren ( ) ;
elements . empty . hidden = filtered . length !== 0 ;
filtered . forEach ( function ( item ) {
var row = document . createElement ( 'tr' ) ;
var name = make ( 'td' ) ;
name . append ( make ( 'span' , titleOf ( item ) , 'title' ) ) ;
if ( subtitleOf ( item ) ) name . append ( make ( 'span' , subtitleOf ( item ) , 'subtitle' ) ) ;
var status = make ( 'td' ) ;
status . append ( make ( 'span' , statusOf ( item ) , 'pill' ) ) ;
row . append ( name , status , make ( 'td' , dateText ( dateOf ( item ) ) ) , make ( 'td' , valueOf ( item ) || '—' ) ) ;
row . className = 'record' ;
row . tabIndex = 0 ;
row . setAttribute ( 'role' , 'button' ) ;
row . setAttribute ( 'aria-label' , titleOf ( item ) ) ;
row . addEventListener ( 'click' , function ( ) { showDetails ( item ) ; } ) ;
row . addEventListener ( 'keydown' , function ( event ) { if ( event . key === 'Enter' || event . key === ' ' ) showDetails ( item ) ; } ) ;
elements . rows . append ( row ) ;
} ) ;
renderSummary ( ) ;
}
function render ( value ) {
records = toArray ( value ) . slice ( ) . sort ( function ( left , right ) {
var a = Date . parse ( dateOf ( left ) ) ; var b = Date . parse ( dateOf ( right ) ) ;
if ( Number . isNaN ( a ) ) a = Number . POSITIVE _INFINITY ;
if ( Number . isNaN ( b ) ) b = Number . POSITIVE _INFINITY ;
return a - b || titleOf ( left ) . localeCompare ( titleOf ( right ) ) ;
} ) ;
renderRows ( ) ;
}
function setStatus ( value , isError ) {
elements . status . textContent = value || '' ;
elements . status . className = isError ? 'status-message error' : 'status-message' ;
}
function detailValue ( value ) {
if ( value === null || value === undefined || value === '' ) return '—' ;
if ( typeof value === 'object' ) {
try { return JSON . stringify ( value ) ; } catch ( error ) { return '—' ; }
}
return String ( value ) ;
}
function addDetail ( label , value ) {
var item = make ( 'div' , undefined , 'detail' ) ;
item . append ( make ( 'span' , label , 'detail-label' ) , make ( 'span' , detailValue ( value ) , 'detail-value' ) ) ;
elements . detailGrid . append ( item ) ;
}
function showDetails ( item ) {
elements . detailsTitle . textContent = titleOf ( item ) ;
elements . detailsSubtitle . textContent = subtitleOf ( item ) || idOf ( item ) ;
elements . detailGrid . replaceChildren ( ) ;
addDetail ( 'ID' , idOf ( item ) ) ;
addDetail ( CONFIG . statusLabel , statusOf ( item ) ) ;
addDetail ( CONFIG . dateLabel , dateText ( dateOf ( item ) ) ) ;
if ( valueOf ( item ) ) addDetail ( CONFIG . valueLabel , valueOf ( item ) ) ;
if ( item . meta ) addDetail ( 'Meta' , item . meta ) ;
elements . details . hidden = false ;
var id = idOf ( item ) ;
if ( ! id || ! CONFIG . detailTool ) return ;
var args = { } ;
args [ CONFIG . detailKey ] = Number ( id ) || id ;
if ( CONFIG . detailSection ) args . section = CONFIG . detailSection ;
request ( 'tools/call' , { name : CONFIG . detailTool , arguments : args } ) . then ( function ( result ) {
var payload = unwrap ( result ) ;
var data = payload && payload . data ;
if ( Array . isArray ( data ) ) data = data [ 0 ] ;
if ( ! data || typeof data !== 'object' ) return ;
elements . detailGrid . replaceChildren ( ) ;
Object . keys ( data ) . slice ( 0 , 12 ) . forEach ( function ( key ) { addDetail ( key , data [ key ] ) ; } ) ;
} ) . catch ( function ( error ) { setStatus ( error && error . message ? error . message : 'Не удалось загрузить детали.' , true ) ; } ) ;
}
function refresh ( ) {
if ( busy ) return ;
busy = true ;
elements . refresh . disabled = true ;
setStatus ( 'Загрузка…' , false ) ;
request ( 'tools/call' , { name : CONFIG . listTool , arguments : CONFIG . listArgs } ) . then ( function ( result ) {
render ( unwrap ( result ) ) ;
setStatus ( '' , false ) ;
} ) . catch ( function ( error ) {
setStatus ( error && error . message ? error . message : 'Не удалось загрузить данные.' , true ) ;
} ) . finally ( function ( ) {
busy = false ;
elements . refresh . disabled = false ;
} ) ;
}
function initializeHost ( ) {
return request ( 'ui/initialize' , {
protocolVersion : '2025-11-25' ,
appInfo : { name : 'SHD ' + CONFIG . title , title : 'SHD ' + CONFIG . title , version : '1.0.0' , websiteUrl : 'https://shd.xyz.su' } ,
appCapabilities : { }
} ) . then ( function ( ) { notify ( 'ui/notifications/initialized' , { } ) ; } ) . catch ( function ( ) { } ) ;
}
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 && pending . has ( message . id ) ) {
var entry = pending . get ( message . id ) ;
pending . delete ( message . id ) ;
window . clearTimeout ( entry . timeout ) ;
if ( message . error ) entry . reject ( new Error ( message . error . message || 'Host request failed' ) ) ;
else entry . resolve ( message . result ) ;
return ;
}
if ( message . method === 'ui/notifications/tool-input' ) {
if ( ! records . length ) render ( message . params || { } ) ;
}
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 . close . addEventListener ( 'click' , function ( ) { elements . details . hidden = true ; } ) ;
void initializeHost ( ) ;
var initialOutput = typeof window !== 'undefined' && window . openai ? window . openai . toolOutput : null ;
if ( initialOutput ) render ( initialOutput ) ;
else {
var initialInput = typeof window !== 'undefined' && window . openai ? window . openai . toolInput : null ;
if ( initialInput ) render ( initialInput ) ;
else refresh ( ) ;
}
} ( ) ) ;
< / script >
< / body >
< / html >