feat(report): P2-B 템플릿 관리 UI — 저장 템플릿 목록/선택/삭제 + 등록·생성 분리
- IReportTemplateStore.ListAsync(생성이력 집계 LEFT JOIN)/DeleteAsync(report_run CASCADE).
- GET /api/report/templates, DELETE /api/report/template/{id}.
- 엑셀폼 패널: 매번 재업로드 → 등록(1회) → 저장 목록에서 라디오 선택 → 생성.
생성횟수·최근시각 표시, 삭제 버튼, 생성 후 목록 자동 갱신.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,17 @@ public sealed class MetricRequestDto
|
||||
public int? SessionId { get; set; } // fast_record일 때
|
||||
}
|
||||
|
||||
/// <summary>템플릿 목록 항목(관리 UI). 생성이력 집계 포함.</summary>
|
||||
public sealed class ReportTemplateInfo
|
||||
{
|
||||
public int Id { get; set; }
|
||||
public string Name { get; set; } = "";
|
||||
public string? Owner { get; set; }
|
||||
public DateTime CreatedAt { get; set; }
|
||||
public int RunCount { get; set; } // 이 템플릿으로 생성한 리포트 수
|
||||
public DateTime? LastRunAt { get; set; } // 마지막 생성 시각(없으면 null)
|
||||
}
|
||||
|
||||
/// <summary>결정론 메트릭 1건 결과 + 해상도 메타(필수, 사과-오렌지 방지).
|
||||
/// 프로퍼티로 선언해야 System.Text.Json이 직렬화함(필드는 기본 미직렬화).</summary>
|
||||
public sealed class MetricResultDto
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
|
||||
namespace Hc900Crawler.Core.Application.Interfaces;
|
||||
|
||||
/// <summary>report_template / report_run CRUD (raw SQL).</summary>
|
||||
@@ -8,4 +10,6 @@ public interface IReportTemplateStore
|
||||
Task RecordRunAsync(int templateId, string periodKind, DateTime periodDate,
|
||||
string sourceTable, string status, object cells, byte[] outBlob,
|
||||
CancellationToken ct = default);
|
||||
Task<IReadOnlyList<ReportTemplateInfo>> ListAsync(CancellationToken ct = default);
|
||||
Task<bool> DeleteAsync(int templateId, CancellationToken ct = default); // 존재 시 true (report_run은 CASCADE)
|
||||
}
|
||||
|
||||
@@ -138,6 +138,11 @@ GROUP BY b ORDER BY b";
|
||||
return Ok(new { Column = column, Date = d.ToString("yyyy-MM-dd"), Source = source, Period = period, Metrics = results });
|
||||
}
|
||||
|
||||
/// <summary>저장된 템플릿 목록(관리 UI) — 생성이력 집계 포함.</summary>
|
||||
[HttpGet("templates")]
|
||||
public async Task<IActionResult> Templates(CancellationToken ct)
|
||||
=> Ok(await _store.ListAsync(ct));
|
||||
|
||||
/// <summary>엑셀 템플릿 등록.</summary>
|
||||
[HttpPost("template")]
|
||||
public async Task<IActionResult> Upload([FromForm] IFormFile file, [FromForm] string name,
|
||||
@@ -150,6 +155,11 @@ GROUP BY b ORDER BY b";
|
||||
return Ok(new { Id = id });
|
||||
}
|
||||
|
||||
/// <summary>템플릿 삭제(생성이력 report_run은 CASCADE).</summary>
|
||||
[HttpDelete("template/{id:int}")]
|
||||
public async Task<IActionResult> Delete(int id, CancellationToken ct)
|
||||
=> await _store.DeleteAsync(id, ct) ? Ok(new { Deleted = id }) : NotFound(new { Error = $"템플릿 {id} 없음" });
|
||||
|
||||
/// <summary>★템플릿+날짜 → 채워진 xlsx 다운로드.</summary>
|
||||
[HttpGet("generate")]
|
||||
public async Task<IActionResult> Generate(int templateId, DateTime date,
|
||||
|
||||
@@ -33,25 +33,71 @@ paneInit['reports'] = function () {
|
||||
} catch (e) { out.innerHTML = `<span class="mono" style="color:#e66">❌ ${typeof esc === 'function' ? esc(e.message) : e.message}</span>`; }
|
||||
};
|
||||
|
||||
// ── ② 엑셀 export ──
|
||||
const tpl = $('rpTpl'), gen = $('rpGen'), status = $('rpStatus');
|
||||
// ── ② 엑셀 export (템플릿 등록 → 목록 선택 → 생성) ──
|
||||
const status = $('rpStatus');
|
||||
const hesc = (s) => String(s ?? '').replace(/[&<>"]/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c]));
|
||||
if ($('rpDate') && !$('rpDate').value) $('rpDate').value = yKst();
|
||||
|
||||
// 저장된 템플릿 목록 로드 + 선택/삭제 와이어링 (멱등)
|
||||
async function loadTemplates() {
|
||||
const box = $('rpTplList'); if (!box) return;
|
||||
try {
|
||||
const list = await fetch('/api/report/templates').then(r => r.json());
|
||||
$('rpTplCount').textContent = `(${list.length})`;
|
||||
const sel = $('rpSelId');
|
||||
if (!list.length) { box.innerHTML = '<span style="color:var(--t2)">등록된 템플릿이 없습니다 — 위에서 .xlsx를 등록하세요.</span>'; sel.value = ''; return; }
|
||||
const keep = list.some(t => String(t.Id) === sel.value) ? sel.value : String(list[0].Id);
|
||||
sel.value = keep;
|
||||
box.innerHTML = list.map(t => {
|
||||
const last = t.LastRunAt ? new Date(t.LastRunAt).toLocaleString() : '미생성';
|
||||
return `<label style="display:flex;align-items:center;gap:8px;padding:3px 0">
|
||||
<input type="radio" name="rpTplPick" value="${t.Id}" ${String(t.Id) === keep ? 'checked' : ''}>
|
||||
<span style="flex:1">#${t.Id} <strong>${hesc(t.Name)}</strong> · 생성 ${t.RunCount}회 · 최근 ${hesc(last)}</span>
|
||||
<button class="btn" data-del="${t.Id}" style="padding:2px 8px">삭제</button></label>`;
|
||||
}).join('');
|
||||
box.querySelectorAll('input[name=rpTplPick]').forEach(r => r.onchange = () => sel.value = r.value);
|
||||
box.querySelectorAll('button[data-del]').forEach(b => b.onclick = async () => {
|
||||
if (!confirm(`템플릿 #${b.dataset.del} 삭제? (생성이력도 함께 삭제됩니다)`)) return;
|
||||
const r = await fetch(`/api/report/template/${b.dataset.del}`, { method: 'DELETE' });
|
||||
status.textContent = r.ok ? `🗑️ #${b.dataset.del} 삭제됨` : `❌ 삭제 실패 ${r.status}`;
|
||||
loadTemplates();
|
||||
});
|
||||
} catch (e) { box.innerHTML = `<span style="color:#e66">목록 로드 실패: ${hesc(e.message)}</span>`; }
|
||||
}
|
||||
loadTemplates();
|
||||
|
||||
// 등록
|
||||
const up = $('rpUpload');
|
||||
if (up) up.onclick = async () => {
|
||||
const f = $('rpTpl').files[0];
|
||||
if (!f) { status.textContent = '⚠️ .xlsx 파일을 선택하세요.'; return; }
|
||||
up.disabled = true; status.textContent = '⏳ 등록 중...';
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', f); fd.append('name', $('rpTplName').value || f.name);
|
||||
const r = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||
if (!r.ok) throw new Error('등록 실패 ' + r.status);
|
||||
$('rpTpl').value = ''; $('rpTplName').value = '';
|
||||
status.textContent = '✅ 등록됨';
|
||||
await loadTemplates();
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { up.disabled = false; }
|
||||
};
|
||||
|
||||
// 생성 (선택된 저장 템플릿)
|
||||
const gen = $('rpGen');
|
||||
if (gen) gen.onclick = async () => {
|
||||
if (!tpl.files[0]) { status.textContent = '⚠️ 엑셀 템플릿을 선택하세요.'; return; }
|
||||
const id = $('rpSelId').value;
|
||||
if (!id) { status.textContent = '⚠️ 생성할 템플릿을 선택하세요.'; return; }
|
||||
gen.disabled = true; status.textContent = '⏳ 생성 중...';
|
||||
try {
|
||||
const fd = new FormData(); fd.append('file', tpl.files[0]); fd.append('name', tpl.files[0].name);
|
||||
const up = await fetch('/api/report/template', { method: 'POST', body: fd });
|
||||
if (!up.ok) throw new Error('업로드 실패 ' + up.status);
|
||||
const { Id } = await up.json();
|
||||
let url = `/api/report/generate?templateId=${Id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
|
||||
let url = `/api/report/generate?templateId=${id}&date=${$('rpDate').value}&source=${$('rpSource').value}`;
|
||||
if ($('rpSource').value === 'fast_record' && $('rpSession').value) url += `&sessionId=${$('rpSession').value}`;
|
||||
const resp = await fetch(url);
|
||||
if (!resp.ok) throw new Error('생성 실패 ' + resp.status);
|
||||
const st = resp.headers.get('X-Report-Status') || '?';
|
||||
const a = document.createElement('a'); a.href = URL.createObjectURL(await resp.blob());
|
||||
a.download = `report_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
a.download = `report_${id}_${$('rpDate').value}.xlsx`; a.click(); URL.revokeObjectURL(a.href);
|
||||
status.textContent = `✅ 완료 (상태=${st})`;
|
||||
loadTemplates(); // 생성횟수·최근시각 갱신
|
||||
} catch (e) { status.textContent = '❌ ' + e.message; } finally { gen.disabled = false; }
|
||||
};
|
||||
};
|
||||
|
||||
@@ -45,8 +45,22 @@
|
||||
<p style="color:var(--t2);font-size:13px;margin-top:0">
|
||||
엑셀 템플릿 셀에 <code>{{ metric=mass_balance_closure; column=C-6111 }}</code> 형태 토큰을 박아두면 선택 날짜 값으로 채워 다운로드합니다.
|
||||
</p>
|
||||
<div style="display:flex;flex-direction:column;gap:10px;max-width:560px">
|
||||
<!-- ⓐ 템플릿 등록 -->
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:640px">
|
||||
<label>① 엑셀 템플릿(.xlsx)<input type="file" id="rpTpl" accept=".xlsx,.xlsm" style="display:block;margin-top:4px"></label>
|
||||
<label>이름(선택)<input type="text" id="rpTplName" placeholder="미입력=파일명" style="display:block;margin-top:4px"></label>
|
||||
<button id="rpUpload" class="btn">등록</button>
|
||||
</div>
|
||||
|
||||
<!-- ⓑ 저장된 템플릿 목록 (선택) -->
|
||||
<div style="margin-top:14px">
|
||||
<div style="font-size:13px;color:var(--t2);margin-bottom:6px">저장된 템플릿 <span id="rpTplCount"></span></div>
|
||||
<div id="rpTplList" class="mono" style="font-size:13px">로딩…</div>
|
||||
<input type="hidden" id="rpSelId">
|
||||
</div>
|
||||
|
||||
<!-- ⓒ 생성 -->
|
||||
<div style="display:flex;gap:8px;align-items:flex-end;flex-wrap:wrap;max-width:640px;margin-top:14px;border-top:1px dashed var(--bd,#333);padding-top:14px">
|
||||
<label>② 날짜(KST)<input type="date" id="rpDate" style="display:block;margin-top:4px"></label>
|
||||
<label>③ 소스
|
||||
<select id="rpSource" style="display:block;margin-top:4px">
|
||||
@@ -55,7 +69,7 @@
|
||||
</select>
|
||||
</label>
|
||||
<label id="rpSessionWrap" style="display:none">session id<input type="number" id="rpSession" style="display:block;margin-top:4px"></label>
|
||||
<button id="rpGen" class="btn" style="align-self:flex-start">리포트 생성·다운로드</button>
|
||||
<button id="rpGen" class="btn">선택 템플릿 생성·다운로드</button>
|
||||
</div>
|
||||
<div id="rpStatus" class="mono" style="margin-top:12px;white-space:pre-wrap"></div>
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Data;
|
||||
using System.Data.Common;
|
||||
using System.Text.Json;
|
||||
using Hc900Crawler.Core.Application.DTOs;
|
||||
using Hc900Crawler.Core.Application.Interfaces;
|
||||
using Hc900Crawler.Infrastructure.Database;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
@@ -53,6 +54,41 @@ VALUES (@t, @pk, @pd, @src, @st, @cells::jsonb, @blob)";
|
||||
await cmd.ExecuteNonQueryAsync(ct);
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<ReportTemplateInfo>> ListAsync(CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = @"
|
||||
SELECT t.id, t.name, t.owner, t.created_at,
|
||||
count(r.id) AS run_count, max(r.generated_at) AS last_run
|
||||
FROM hc900.report_template t
|
||||
LEFT JOIN hc900.report_run r ON r.template_id = t.id
|
||||
GROUP BY t.id, t.name, t.owner, t.created_at
|
||||
ORDER BY t.created_at DESC";
|
||||
var list = new List<ReportTemplateInfo>();
|
||||
await using var rd = await cmd.ExecuteReaderAsync(ct);
|
||||
while (await rd.ReadAsync(ct))
|
||||
list.Add(new ReportTemplateInfo
|
||||
{
|
||||
Id = rd.GetInt32(0),
|
||||
Name = rd.GetString(1),
|
||||
Owner = rd.IsDBNull(2) ? null : rd.GetString(2),
|
||||
CreatedAt = rd.GetFieldValue<DateTime>(3),
|
||||
RunCount = Convert.ToInt32(rd.GetValue(4)),
|
||||
LastRunAt = rd.IsDBNull(5) ? null : rd.GetFieldValue<DateTime>(5),
|
||||
});
|
||||
return list;
|
||||
}
|
||||
|
||||
public async Task<bool> DeleteAsync(int templateId, CancellationToken ct = default)
|
||||
{
|
||||
var conn = await OpenAsync(ct);
|
||||
await using var cmd = conn.CreateCommand();
|
||||
cmd.CommandText = "DELETE FROM hc900.report_template WHERE id=@id"; // report_run은 FK ON DELETE CASCADE
|
||||
AddP(cmd, "@id", templateId);
|
||||
return await cmd.ExecuteNonQueryAsync(ct) > 0;
|
||||
}
|
||||
|
||||
private async Task<DbConnection> OpenAsync(CancellationToken ct)
|
||||
{
|
||||
var conn = _ctx.Database.GetDbConnection();
|
||||
|
||||
Reference in New Issue
Block a user