feat: add Excel document generator

This commit is contained in:
binzz
2026-08-12 10:11:48 +09:00
parent b60b933a08
commit ccabd1187b
10 changed files with 1433 additions and 0 deletions

View File

@@ -12,6 +12,383 @@
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@fontsource/geist-mono@5.0.1/400.css">
<!-- Bootstrap CSS -->
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/css/bootstrap.min.css" rel="stylesheet">
<style id="document-generator-styles">
.document-generator {
--docgen-panel: #111113;
--docgen-panel-soft: #18181b;
--docgen-line: #2a2a2f;
--docgen-line-strong: #3f3f46;
--docgen-text: #f4f4f5;
--docgen-muted: #92929d;
--docgen-blue: #3b82f6;
--docgen-blue-soft: rgba(59, 130, 246, 0.12);
--docgen-green: #6ee7a0;
--docgen-amber: #facc50;
--docgen-danger: #fb7185;
color: var(--docgen-text);
}
.document-generator *,
.document-generator *::before,
.document-generator *::after {
box-sizing: border-box;
}
.document-generator .docgen-workspace {
display: grid;
grid-template-columns: minmax(340px, 0.9fr) minmax(460px, 1.35fr);
margin: -2rem -1.5rem;
min-height: 680px;
}
.document-generator .docgen-config,
.document-generator .docgen-preview {
padding: 28px;
}
.document-generator .docgen-config {
border-right: 1px solid var(--docgen-line);
}
.document-generator .docgen-section-title {
margin: 0 0 6px;
color: var(--docgen-text);
font-size: 16px;
font-weight: 650;
}
.document-generator .docgen-section-copy {
margin: 0 0 24px;
color: var(--docgen-muted);
font-size: 13px;
line-height: 1.6;
}
.document-generator .docgen-field {
margin-bottom: 25px;
}
.document-generator .docgen-field-label {
display: block;
margin-bottom: 9px;
color: var(--docgen-text);
font-size: 13px;
font-weight: 650;
}
.document-generator select,
.document-generator input[type="text"] {
width: 100%;
padding: 13px 14px;
border: 1px solid var(--docgen-line-strong);
border-radius: 7px;
outline: 0;
background: var(--docgen-panel-soft);
color: var(--docgen-text);
font: inherit;
}
.document-generator select:focus,
.document-generator input[type="text"]:focus {
border-color: var(--docgen-blue);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.12);
}
.document-generator .docgen-hint {
margin-top: 9px;
color: var(--docgen-muted);
font-size: 12px;
}
.document-generator .docgen-two-col {
display: grid;
grid-template-columns: 1fr 150px;
gap: 18px;
}
.document-generator .docgen-check-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 10px;
}
.document-generator .docgen-check-card {
display: flex;
align-items: center;
min-height: 58px;
gap: 11px;
padding: 12px 14px;
border: 1px solid var(--docgen-line-strong);
border-radius: 7px;
background: var(--docgen-panel-soft);
color: #d4d4d8;
cursor: pointer;
font-size: 13px;
}
.document-generator .docgen-check-card:hover {
border-color: #52525b;
}
.document-generator .docgen-check-card:has(input:checked) {
border-color: rgba(59, 130, 246, 0.72);
background: var(--docgen-blue-soft);
color: #fff;
}
.document-generator input[type="checkbox"] {
width: 17px;
height: 17px;
margin: 0;
accent-color: var(--docgen-blue);
}
.document-generator .docgen-mini-badge {
margin-left: auto;
padding: 4px 7px;
border: 1px solid var(--docgen-line-strong);
border-radius: 5px;
background: #27272a;
color: #a1a1aa;
font: 600 10px 'Geist Mono', monospace;
}
.document-generator .docgen-action-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 18px;
margin-top: 30px;
padding-top: 24px;
border-top: 1px solid var(--docgen-line);
}
.document-generator .docgen-source-label {
color: var(--docgen-muted);
font-size: 12px;
}
.document-generator .docgen-primary {
padding: 13px 22px;
border: 0;
border-radius: 7px;
background: var(--docgen-blue);
color: #fff;
cursor: pointer;
font-weight: 700;
white-space: nowrap;
}
.document-generator .docgen-primary:hover {
background: #2563eb;
}
.document-generator .docgen-primary:disabled {
opacity: 0.45;
cursor: not-allowed;
}
.document-generator .docgen-meta-card {
min-height: 155px;
padding: 20px;
border: 1px solid var(--docgen-line-strong);
border-radius: 8px;
background: var(--docgen-panel-soft);
}
.document-generator .docgen-meta-title {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 20px;
}
.document-generator .docgen-meta-title h2 {
margin: 0 0 8px;
color: var(--docgen-text);
font-size: 17px;
}
.document-generator .docgen-meta-title p {
margin: 0;
color: var(--docgen-muted);
font-size: 13px;
line-height: 1.5;
}
.document-generator .docgen-status {
padding: 5px 8px;
border: 1px solid rgba(110, 231, 160, 0.28);
border-radius: 5px;
background: rgba(110, 231, 160, 0.07);
color: var(--docgen-green);
font: 700 10px 'Geist Mono', monospace;
white-space: nowrap;
}
.document-generator .docgen-meta-badges {
display: flex;
flex-wrap: wrap;
gap: 8px;
margin-top: 24px;
}
.document-generator .docgen-tag {
padding: 5px 8px;
border: 1px solid #3f3f46;
border-radius: 5px;
background: #27272a;
color: #a1a1aa;
font: 600 10px 'Geist Mono', monospace;
}
.document-generator .docgen-tag.docgen-blue {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.08);
color: #60a5fa;
}
.document-generator .docgen-list {
display: grid;
gap: 10px;
margin-top: 18px;
}
.document-generator .docgen-row {
display: grid;
grid-template-columns: 42px 1fr auto;
align-items: center;
min-height: 68px;
gap: 14px;
padding: 13px 16px;
border: 1px solid var(--docgen-line);
border-radius: 7px;
background: #151518;
opacity: 0.42;
transition: opacity 0.15s, border-color 0.15s;
}
.document-generator .docgen-row.docgen-enabled {
border-color: #303037;
opacity: 1;
}
.document-generator .docgen-number {
display: grid;
place-items: center;
width: 34px;
height: 34px;
border-radius: 6px;
background: #27272a;
color: #a1a1aa;
font: 600 11px 'Geist Mono', monospace;
}
.document-generator .docgen-name {
margin-bottom: 5px;
color: var(--docgen-text);
font-size: 13px;
font-weight: 700;
}
.document-generator .docgen-description {
color: var(--docgen-muted);
font-size: 11px;
}
.document-generator .docgen-mode {
padding: 5px 8px;
border: 1px solid #3f3f46;
border-radius: 5px;
background: #27272a;
color: var(--docgen-muted);
font: 700 10px 'Geist Mono', monospace;
}
.document-generator .docgen-mode.docgen-auto { color: var(--docgen-green); }
.document-generator .docgen-mode.docgen-mixed { color: var(--docgen-amber); }
.document-generator .docgen-mode.docgen-separate {
border-color: #2563eb;
background: rgba(37, 99, 235, 0.08);
color: #60a5fa;
}
.document-generator .docgen-legend {
margin: 16px 0 10px;
color: var(--docgen-muted);
font-size: 11px;
line-height: 1.6;
}
.document-generator .docgen-files {
display: grid;
gap: 10px;
}
.document-generator .docgen-file {
min-height: 72px;
padding: 14px 16px;
border: 1px dashed #3f3f46;
border-radius: 7px;
}
.document-generator .docgen-file.docgen-disabled {
display: none;
}
.document-generator .docgen-file-kind {
margin-bottom: 9px;
color: var(--docgen-green);
font: 700 10px 'Geist Mono', monospace;
}
.document-generator .docgen-file-name {
color: #a1a1aa;
font: 500 11px 'Geist Mono', monospace;
word-break: break-all;
}
.document-generator .docgen-loading { color: var(--docgen-muted); }
.document-generator .docgen-error { color: var(--docgen-danger); }
.docgen-toast {
position: fixed;
right: 24px;
bottom: 24px;
z-index: 1080;
padding: 13px 16px;
border: 1px solid #3f3f46;
border-radius: 8px;
background: #18181b;
box-shadow: 0 18px 50px rgba(0, 0, 0, 0.45);
color: #e4e4e7;
font-size: 12px;
opacity: 0;
pointer-events: none;
transform: translateY(10px);
transition: 0.2s;
}
.docgen-toast.docgen-show {
opacity: 1;
transform: translateY(0);
}
@media (max-width: 900px) {
.document-generator .docgen-workspace {
grid-template-columns: 1fr;
}
.document-generator .docgen-config {
border-right: 0;
border-bottom: 1px solid var(--docgen-line);
}
.document-generator .docgen-two-col {
grid-template-columns: 1fr;
}
}
</style>
<style>
:root {
--bg-color: #09090b;
@@ -405,6 +782,9 @@
<li class="nav-item" role="presentation">
<button class="nav-link" id="tool-tab" data-bs-toggle="tab" data-bs-target="#tool" type="button" role="tab">Tool Function</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="document-tab" data-bs-toggle="tab" data-bs-target="#document" type="button" role="tab" onclick="loadDocumentGenerator()">Document Generator</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" id="list-tab" data-bs-toggle="tab" data-bs-target="#list" type="button" role="tab" onclick="loadToolList()">Registry</button>
</li>
@@ -595,6 +975,78 @@
</form>
</div>
<!-- Document Generator -->
<div class="tab-pane fade document-generator" id="document" role="tabpanel" aria-labelledby="document-tab">
<div class="docgen-workspace">
<section class="docgen-config">
<h2 class="docgen-section-title">Document Generator</h2>
<p class="docgen-section-copy">Tool Metadata를 기준으로 개발 산출물 Excel을 생성합니다. 선택된 문서만 파일과 시트에 포함됩니다.</p>
<div class="docgen-field">
<label class="docgen-field-label" for="docgenToolSelect">Tool</label>
<select id="docgenToolSelect" disabled><option>Tool Metadata를 불러오는 중...</option></select>
<div id="docgenToolHint" class="docgen-hint">Gateway의 ToolMetadata 목록을 조회하고 있습니다.</div>
</div>
<div class="docgen-two-col docgen-field">
<div>
<label class="docgen-field-label" for="docgenDocumentType">Document Type</label>
<select id="docgenDocumentType"><option>Program Definition / Design</option></select>
</div>
<div>
<label class="docgen-field-label" for="docgenVersion">Version</label>
<input id="docgenVersion" type="text" value="1.0" autocomplete="off">
</div>
</div>
<div class="docgen-field">
<label class="docgen-field-label">Included Sheets</label>
<div class="docgen-check-grid">
<label class="docgen-check-card"><input id="docgenProgramCheck" type="checkbox" checked><span>프로그램정의서</span></label>
<label class="docgen-check-card"><input id="docgenProcessCheck" type="checkbox" checked><span>처리설계</span></label>
<label class="docgen-check-card"><input id="docgenInterfaceCheck" type="checkbox" checked><span>인터페이스</span><span class="docgen-mini-badge">별도 파일</span></label>
<label class="docgen-check-card"><input id="docgenRevisionCheck" type="checkbox" checked><span>개정이력</span></label>
</div>
</div>
<div class="docgen-action-row">
<span class="docgen-source-label">Tool metadata · Generated XLSX</span>
<button id="docgenGenerateBtn" class="docgen-primary" type="button" disabled>Generate EXCEL</button>
</div>
</section>
<section class="docgen-preview">
<div class="docgen-meta-card">
<div class="docgen-meta-title">
<div>
<h2 id="docgenToolTitle" class="docgen-loading">Tool Metadata loading...</h2>
<p id="docgenToolDescription">등록된 Tool 목록을 가져오는 중입니다.</p>
</div>
<span id="docgenMetadataStatus" class="docgen-status">SYNC</span>
</div>
<div class="docgen-meta-badges">
<span id="docgenCategoryTag" class="docgen-tag">Category · -</span>
<span id="docgenInterfaceTag" class="docgen-tag">Interface · -</span>
<span class="docgen-tag docgen-blue">Tool Metadata</span>
</div>
</div>
<div class="docgen-list">
<div id="docgenProgramRow" class="docgen-row docgen-enabled"><span class="docgen-number">01</span><div><div class="docgen-name">프로그램정의서</div><div class="docgen-description">Basic information · Runtime · Process summary</div></div><span class="docgen-mode docgen-auto">AUTO</span></div>
<div id="docgenProcessRow" class="docgen-row docgen-enabled"><span class="docgen-number">02</span><div><div class="docgen-name">처리설계</div><div class="docgen-description">Tool metadata + common design</div></div><span class="docgen-mode docgen-mixed">MIXED</span></div>
<div id="docgenInterfaceRow" class="docgen-row docgen-enabled"><span class="docgen-number">03</span><div><div class="docgen-name">인터페이스</div><div class="docgen-description">Request In · Response Out · 2 sheets</div></div><span class="docgen-mode docgen-separate">2 SHEETS · SEPARATE</span></div>
<div id="docgenRevisionRow" class="docgen-row docgen-enabled"><span class="docgen-number">04</span><div><div class="docgen-name">개정이력</div><div class="docgen-description">Version · Date · Change information</div></div><span class="docgen-mode">MANUAL</span></div>
</div>
<p class="docgen-legend">AUTO: Tool metadata · MIXED: Metadata + common design · MANUAL: Review required<br>Output Files · 인터페이스 정의서는 별도 Excel 파일로 생성됩니다.</p>
<div class="docgen-files">
<div id="docgenProgramFile" class="docgen-file"><div class="docgen-file-kind">PROGRAM SPEC</div><div id="docgenProgramFileName" class="docgen-file-name">tool_프로그램정의서_v1.0_YYYYMMDD.xlsx</div></div>
<div id="docgenInterfaceFile" class="docgen-file"><div class="docgen-file-kind">INTERFACE SPEC · REQUEST IN / RESPONSE OUT</div><div id="docgenInterfaceFileName" class="docgen-file-name">tool_인터페이스정의서_v1.0_YYYYMMDD.xlsx</div></div>
</div>
</section>
</div>
</div>
<!-- Tool List Form -->
<div class="tab-pane fade" id="list" role="tabpanel">
<div class="d-flex justify-content-between align-items-center mb-4">
@@ -736,6 +1188,192 @@
<!-- Bootstrap JS -->
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.0/dist/js/bootstrap.bundle.min.js"></script>
<script id="document-generator-script">
(() => {
'use strict';
const state = { tools: [], selected: null };
let checks = null;
let initialized = false;
const dg = (id) => document.getElementById(id);
function initialize() {
if (!initialized) {
checks = {
program: dg('docgenProgramCheck'),
process: dg('docgenProcessCheck'),
interface: dg('docgenInterfaceCheck'),
revision: dg('docgenRevisionCheck')
};
Object.entries(checks).forEach(([name, checkbox]) => checkbox.addEventListener('change', () => {
dg(`docgen${capitalize(name)}Row`).classList.toggle('docgen-enabled', checkbox.checked);
updateOutputPreview();
}));
dg('docgenToolSelect').addEventListener('change', selectTool);
dg('docgenVersion').addEventListener('input', updateOutputPreview);
dg('docgenGenerateBtn').addEventListener('click', generateFiles);
initialized = true;
}
loadTools();
}
async function loadTools() {
const select = dg('docgenToolSelect');
const button = dg('docgenGenerateBtn');
select.disabled = true;
button.disabled = true;
dg('docgenToolHint').textContent = 'Gateway의 ToolMetadata 목록을 조회하고 있습니다.';
try {
const response = await fetch('/mcp/api/v1/tools/list', { cache: 'no-store' });
if (!response.ok) throw new Error(`Gateway HTTP ${response.status}`);
const payload = await response.json();
const tools = payload?.result?.tools;
if (!Array.isArray(tools)) throw new Error('ToolMetadata 응답 형식이 올바르지 않습니다.');
state.tools = tools.slice().sort((a, b) =>
(a.categoryKey || '').localeCompare(b.categoryKey || '') ||
(a.name || '').localeCompare(b.name || '')
);
select.innerHTML = state.tools.map((tool, index) =>
`<option value="${index}">[${escapeHtml(tool.categoryKey || 'etc')}] ${escapeHtml(tool.name || tool.displayName || tool.uid)}</option>`
).join('');
select.disabled = state.tools.length === 0;
dg('docgenToolHint').textContent = `ToolMetadata에 등록된 ${state.tools.length}개 Tool을 모두 표시합니다.`;
if (state.tools.length) {
select.value = '0';
selectTool();
} else {
showMetadataError('등록된 Tool이 없습니다.');
}
} catch (error) {
state.tools = [];
state.selected = null;
showMetadataError(error.message);
select.innerHTML = '<option>Tool Metadata 조회 실패</option>';
dg('docgenToolHint').innerHTML = `<span class="docgen-error">${escapeHtml(error.message)}</span> · Gateway 실행 상태를 확인하세요.`;
}
}
function selectTool() {
state.selected = state.tools[Number(dg('docgenToolSelect').value)] || null;
const tool = state.selected;
if (!tool) return;
dg('docgenVersion').value = normalizeVersion(tool.semver || dg('docgenVersion').value || '1.0');
dg('docgenToolTitle').classList.remove('docgen-loading', 'docgen-error');
dg('docgenToolTitle').textContent = tool.displayName || tool.name;
dg('docgenToolDescription').textContent = tool.description || '설명이 등록되지 않은 Tool입니다.';
dg('docgenCategoryTag').textContent = `Category · ${tool.categoryKey || '-'}`;
dg('docgenInterfaceTag').textContent = `Interface · ${tool.integrationType || '-'}`;
dg('docgenMetadataStatus').textContent = tool.enabled === false ? 'DISABLED' : 'READY';
updateOutputPreview();
}
function showMetadataError(message) {
dg('docgenToolTitle').className = 'docgen-error';
dg('docgenToolTitle').textContent = 'Tool Metadata unavailable';
dg('docgenToolDescription').textContent = message;
dg('docgenMetadataStatus').textContent = 'ERROR';
dg('docgenGenerateBtn').disabled = true;
}
function updateOutputPreview() {
const toolName = safeFilename(state.selected?.displayName || state.selected?.name || 'tool');
const version = safeVersion(dg('docgenVersion').value);
const date = yyyymmdd();
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
dg('docgenProgramFile').classList.toggle('docgen-disabled', !hasProgram);
dg('docgenInterfaceFile').classList.toggle('docgen-disabled', !checks.interface.checked);
dg('docgenProgramFileName').textContent = `${toolName}_프로그램정의서_v${version}_${date}.xlsx`;
dg('docgenInterfaceFileName').textContent = `${toolName}_인터페이스정의서_v${version}_${date}.xlsx`;
dg('docgenGenerateBtn').disabled = !state.selected || (!hasProgram && !checks.interface.checked);
}
async function generateFiles() {
const tool = state.selected;
if (!tool) return notify('생성할 Tool을 선택하세요.');
const button = dg('docgenGenerateBtn');
const version = safeVersion(dg('docgenVersion').value);
const date = yyyymmdd();
const toolName = safeFilename(tool.displayName || tool.name || 'tool');
const hasProgram = checks.program.checked || checks.process.checked || checks.revision.checked;
const request = {
tool,
version,
includeProgram: checks.program.checked,
includeProcess: checks.process.checked,
includeRevision: checks.revision.checked
};
button.disabled = true;
button.textContent = 'Generating...';
try {
if (hasProgram) {
await requestDownload('/mcp/api/v1/admin/documents/program', request,
`${toolName}_프로그램정의서_v${version}_${date}.xlsx`);
}
if (checks.interface.checked) {
await requestDownload('/mcp/api/v1/admin/documents/interface', request,
`${toolName}_인터페이스정의서_v${version}_${date}.xlsx`);
}
notify('선택한 Excel 문서 다운로드를 완료했습니다.');
} catch (error) {
notify(error.message);
} finally {
button.textContent = 'Generate EXCEL';
updateOutputPreview();
}
}
async function requestDownload(url, payload, filename) {
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (!response.ok) {
let message = `문서 생성에 실패했습니다. (HTTP ${response.status})`;
try {
const error = await response.json();
if (error?.error) message = error.error;
} catch (_) {
// JSON 오류 응답이 아닌 경우 기본 메시지를 사용합니다.
}
throw new Error(message);
}
const blob = await response.blob();
const href = URL.createObjectURL(blob);
const anchor = document.createElement('a');
anchor.href = href;
anchor.download = filename;
document.body.appendChild(anchor);
anchor.click();
anchor.remove();
URL.revokeObjectURL(href);
}
function safeFilename(value) { return String(value).replace(/[\\/:*?"<>|]/g, '_').trim() || 'tool'; }
function safeVersion(value) { return String(value || '1.0').replace(/^v/i, '').replace(/[^0-9A-Za-z._-]/g, '_') || '1.0'; }
function normalizeVersion(value) { return String(value).replace(/^v/i, '') || '1.0'; }
function yyyymmdd() {
const date = new Date();
return `${date.getFullYear()}${String(date.getMonth() + 1).padStart(2, '0')}${String(date.getDate()).padStart(2, '0')}`;
}
function capitalize(value) { return value.charAt(0).toUpperCase() + value.slice(1); }
function escapeHtml(value) {
return String(value).replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
.replace(/"/g, '&quot;').replace(/'/g, '&apos;');
}
function notify(message) {
const toast = dg('docgenToast');
toast.textContent = message;
toast.classList.add('docgen-show');
window.setTimeout(() => toast.classList.remove('docgen-show'), 2800);
}
window.loadDocumentGenerator = initialize;
})();
</script>
<div id="docgenToast" class="docgen-toast"></div>
<script>
function handleFormSubmit(formId, apiUrl) {
document.getElementById(formId).addEventListener('submit', function(e) {