feat: Point Builder 실시간 추가/제거(선택분만) + 미리보기 닫기

- 전체 태그 목록 체크박스 + 버튼을 realtime_table 기준으로 전환
  · + 실시간 추가: 선택 태그만 is_active+realtime_enabled 켜고 realtime_table 직접 등록(즉시 라이브)
  · - 실시간 제거: realtime_enabled 끄고 realtime_table 행 직접 삭제
  · full-Sync 미사용(전체 추가 사고 방지) → 선택 id만 insert/delete
  · 신규 API: POST /api/pointbuilder/realtime-add, realtime-remove
  · 레거시 pbBulkActivate(is_active만 토글) 제거
- 미리보기 ✕ 닫기 버튼 추가(설정 화면 복귀)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-06-10 21:27:47 +09:00
parent 56df27c85f
commit 8c7b25e667
4 changed files with 142 additions and 17 deletions

View File

@@ -332,6 +332,36 @@ public class PointBuilderController : ControllerBase
return Ok(new { success = true, count, message = $"{count}개 포인트 추가 완료" });
}
public sealed record RealtimeBulkDto(List<int>? Ids, string? ControllerId);
// 전체 태그 목록에서 선택(id)한 태그를 realtime_table에 즉시 라이브로 편입
[HttpPost("realtime-add")]
public async Task<IActionResult> RealtimeAdd([FromBody] RealtimeBulkDto dto)
{
if (dto.Ids == null || dto.Ids.Count == 0)
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
var count = await _db.Hc900RealtimeAddByIdsAsync(dto.ControllerId, dto.Ids);
_log.LogInformation("[PointBuilder] 실시간 추가: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 실시간 편입 완료" });
}
// 선택(id)한 태그를 realtime_table에서 제거
[HttpPost("realtime-remove")]
public async Task<IActionResult> RealtimeRemove([FromBody] RealtimeBulkDto dto)
{
if (dto.Ids == null || dto.Ids.Count == 0)
return BadRequest(new { success = false, error = "ids는 최소 1개 이상 필요합니다" });
if (string.IsNullOrEmpty(dto.ControllerId))
return BadRequest(new { success = false, error = "controllerId는 필수입니다" });
var count = await _db.Hc900RealtimeRemoveByIdsAsync(dto.ControllerId, dto.Ids);
_log.LogInformation("[PointBuilder] 실시간 제거: {Count}개 (controller={Ctrl})", count, dto.ControllerId);
return Ok(new { success = true, count, message = $"{count}개 실시간 제거 완료" });
}
[HttpGet("points")]
public async Task<IActionResult> GetPoints(
[FromQuery] string? rt = null, // null/"" = 전체, "only" = 실시간(realtime_table만), "exclude" = 실시간 제외

View File

@@ -70,6 +70,13 @@ async function pbPreview() {
}
}
function pbClosePreview() {
const area = document.getElementById('pb-preview-area');
if (area) area.style.display = 'none';
const chk = document.getElementById('pb-chk-all-preview');
if (chk) chk.checked = false;
}
async function pbBuild() {
if (!confirm('모든 활성 태그를 해제하고 조건에 맞는 태그만 활성화합니다. 계속하시겠습니까?')) return;
const payload = pbCollectAllGroups();
@@ -322,6 +329,39 @@ function pbUpdateBulkBar() {
const checked = document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked');
const bar = document.getElementById('pb-points-bulk');
bar.hidden = checked.length === 0;
const cnt = document.getElementById('pb-points-selcount');
if (cnt) cnt.textContent = checked.length ? `${checked.length}개 선택` : '';
}
function pbSelectedPointIds() {
return Array.from(document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked'))
.map(cb => parseInt(cb.value)).filter(n => !isNaN(n));
}
// 선택 태그를 realtime_table에 즉시 라이브로 편입
async function pbRealtimeAdd() {
const ids = pbSelectedPointIds();
const ctrl = pbGetControllerId();
if (ids.length === 0) return;
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에 추가합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/pointbuilder/realtime-add', 'POST', { ids, controllerId: ctrl });
alert(res.message || `${res.count}개 실시간 편입`);
pbRefresh();
pbLoadSummary();
}
// 선택 태그를 realtime_table에서 제거
async function pbRealtimeRemove() {
const ids = pbSelectedPointIds();
const ctrl = pbGetControllerId();
if (ids.length === 0) return;
if (!ctrl) { alert('컨트롤러를 선택해주세요.'); return; }
if (!confirm(`[${ctrl}] 선택한 ${ids.length}개를 실시간(realtime_table)에서 제거합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/pointbuilder/realtime-remove', 'POST', { ids, controllerId: ctrl });
alert(res.message || `${res.count}개 실시간 제거`);
pbRefresh();
pbLoadSummary();
}
async function pbDeleteOne(id) {
@@ -333,19 +373,6 @@ async function pbDeleteOne(id) {
}
}
async function pbBulkActivate(active) {
const ids = Array.from(document.querySelectorAll('#pb-points-tbody .pb-point-chk:checked'))
.map(cb => parseInt(cb.value));
const ctrl = pbGetControllerId();
if (ids.length === 0) return;
if (!confirm(`[${ctrl || '전체'}] 선택한 ${ids.length}개를 ${active ? '활성화' : '비활성화'}합니다. 계속하시겠습니까?`)) return;
const res = await pbApi('/api/hc900/tags/bulk-active', 'POST', { ids, active, controllerId: ctrl || undefined });
if (res) {
pbRefresh();
pbLoadSummary();
}
}
// ── Sinam xlsx 파싱 ────────────────────────────────────────────────────────────
let _pbSinamBusy = false;

View File

@@ -189,10 +189,11 @@
<div id="pb-preview-area" class="pb-preview" style="display:none">
<div class="pb-preview-header">
<span>미리보기 <span id="pb-preview-count" style="color:var(--a)"></span></span>
<div class="pb-preview-actions">
<div class="pb-preview-actions" style="display:flex;align-items:center;gap:10px">
<label style="font-size:12px;display:flex;align-items:center;gap:4px;cursor:pointer">
<input type="checkbox" id="pb-chk-all-preview" onchange="pbToggleAllPreview(this)"> 전체 선택
</label>
<button class="btn-c" style="padding:3px 10px;font-size:11px" onclick="pbClosePreview()">✕ 닫기</button>
</div>
</div>
<div style="overflow-x:auto">
@@ -297,9 +298,10 @@
<option value="exclude">실시간 제외</option>
</select>
<button class="btn-c" onclick="pbReload()" style="padding:3px 10px;font-size:11px">조회</button>
<div style="display:flex;gap:6px" id="pb-points-bulk" hidden>
<button class="btn-a" style="padding:3px 10px;font-size:11px" onclick="pbBulkActivate(true)">✓ 선택 활성화</button>
<button class="btn-b" style="padding:3px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbBulkActivate(false)">✗ 선택 비활성화</button>
<div style="display:flex;gap:6px;align-items:center" id="pb-points-bulk" hidden>
<span id="pb-points-selcount" style="font-size:11px;color:var(--t2)"></span>
<button class="btn-a" style="padding:3px 10px;font-size:11px" onclick="pbRealtimeAdd()"> 실시간 추가</button>
<button class="btn-b" style="padding:3px 10px;font-size:11px;background:#5a1a1a;color:#faa" onclick="pbRealtimeRemove()"> 실시간 제거</button>
</div>
</div>
</div>

View File

@@ -381,6 +381,72 @@ public class Hc900DbContext : DbContext
}
}
// 선택 태그(id)만 realtime_table에 편입(즉시 라이브). full-Sync 금지(전체 추가됨) → 선택분만 직접 insert.
public async Task<int> Hc900RealtimeAddByIdsAsync(string controllerId, IEnumerable<int> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0) return 0;
using var tx = await Database.BeginTransactionAsync();
try
{
var entries = await Hc900MapEntries
.Where(x => idList.Contains(x.Id) && x.ControllerId == controllerId)
.ToListAsync();
// 폴링 대상화: is_active + realtime_enabled
foreach (var e in entries) { e.IsActive = true; e.RealtimeEnabled = true; }
// realtime_table에 없는 것만 직접 등록
var tagNames = entries.Select(e => e.TagName).ToList();
var existing = await RealtimePoints
.Where(r => r.ControllerId == controllerId && tagNames.Contains(r.TagName))
.Select(r => r.TagName).ToListAsync();
var toAdd = entries.Where(e => !existing.Contains(e.TagName))
.Select(e => new RealtimePoint
{
ControllerId = controllerId,
TagName = e.TagName,
NodeId = e.Hc900Tag,
LiveValue = null,
Timestamp = DateTime.UtcNow,
});
RealtimePoints.AddRange(toAdd);
await SaveChangesAsync();
await tx.CommitAsync();
return entries.Count;
}
catch { await tx.RollbackAsync(); throw; }
}
// 선택 태그(id)만 realtime_table에서 제거. realtime_enabled=FALSE로 폴링 재추가 차단 + 행 직접 삭제.
public async Task<int> Hc900RealtimeRemoveByIdsAsync(string controllerId, IEnumerable<int> ids)
{
var idList = ids.Distinct().ToList();
if (idList.Count == 0) return 0;
using var tx = await Database.BeginTransactionAsync();
try
{
var entries = await Hc900MapEntries
.Where(x => idList.Contains(x.Id) && x.ControllerId == controllerId)
.ToListAsync();
var tagNames = entries.Select(e => e.TagName).ToList();
// 폴링이 다시 upsert하지 않도록 realtime_enabled 끄기
foreach (var e in entries) e.RealtimeEnabled = false;
var rows = await RealtimePoints
.Where(r => r.ControllerId == controllerId && tagNames.Contains(r.TagName))
.ToListAsync();
RealtimePoints.RemoveRange(rows);
await SaveChangesAsync();
await tx.CommitAsync();
return rows.Count;
}
catch { await tx.RollbackAsync(); throw; }
}
public async Task<RealtimePoint> Hc900AddRealtimePointAsync(
string tagName, string hc900Tag, int modbusAddr,
string dataType, string? paramType, string access, string controllerId)