# 포인트빌더 개선 방안 - 코딩 2

## 1. 문제 정의

현재 "테이블 작성하기" 버튼은 조건에 맞는 포인트를 즉시 `realtime_table`에 TRUNCATE + INSERT 합니다.

**문제:**
- 빌드 결과가 의도와 다른지 확인할 수 없음
- 잘못된 조건으로 빌드 시 기존 liveValue 데이터 손실
- 521,958개 node_map_master 레코드 중 실수로 전체 선택 시 대규모 데이터 삽입 가능

**목표:** 빌드 전에 결과 미리보기 → 개별 체크 → 원하는 것만 선택하여 적용

---

## 2. 효율적 UI 구성 방안

### 2.1 왜 모달(팝업)이 아닌 인라인 확장인가?

| 고려사항 | 모달 | 인라인 확장 |
|----------|------|-------------|
| 포인트 수 | 수백~수천 개 | 동일 |
| 스크롤 | 모달 내부 스크롤 + 페이지 스크롤 충돌 | 자연스러운 페이지 흐름 |
| 체크 상태 유지 | 모달 닫으면 초기화 | 유지 가능 |
| 테이블 비교 | 기존 테이블과 별도 창 | 바로 아래에 표시, 비교 용이 |
| 모바일 | 화면 절반 가림 | 자연스럽게 스크롤 |

**결정: 인라인 확장** — "테이블 작성하기" 옆에 "미리보기" 버튼 추가, 결과를 기존 포인트 목록 위에 인라인으로 표시.

### 2.2 UI 플로우

```
┌─────────────────────────────────────────────────────────────────────┐
│  조건으로 테이블 작성                                                 │
│  ┌─ 컨트롤러 포인트 #1 ──────────────────────────────────────────────┐ │
│  │ ... (조건 입력)                                                    │ │
│  └───────────────────────────────────────────────────────────────────┘ │
│  ┌─ 아날로그 모니터링 포인트 #2 ────────────────────────────────────┐ │
│  │ ... (조건 입력)                                                    │ │
│  └───────────────────────────────────────────────────────────────────┘ │
│  (나머지 그룹...)                                                      │
│                                                                       │
│  [🔍 미리보기]  [🔨 테이블 작성하기]  [📋 테이블 조회]                   │
│                                                                       │
│  ▼ (미리보기 클릭 후 인라인 표시)                                      │
│  ┌───────────────────────────────────────────────────────────────────┐ │
│  │ 미리보기 결과 (127개 포인트)                                       │ │
│  │ [전체 선택] [전체 해제] [역전]  선택: 127/127                     │ │
│  │ ┌──┬ ID  ┬ TagName          ┬ NodeType ┬ DataType┐              │ │
│  │ │☑ │ 1   │ FICQ-2113.PV     │ pv       │ Double  │              │ │
│  │ │☑ │ 2   │ FICQ-2113.OP     │ op       │ Double  │              │ │
│  │ │☐ │ 3   │ FICQ-2113.SP     │ sp       │ Double  │              │ │
│  │ │☑ │ 4   │ TIC-2101.PV      │ pv       │ Double  │              │ │
│  │ │  │ ... │ ...               │ ...      │ ...     │              │ │
│  │ └──┴─────┴──────────────────┴──────────┴─────────┘              │ │
│  │ [취소]                    [선택된 126개 적용하기]                  │ │
│  └───────────────────────────────────────────────────────────────────┘ │
│                                                                       │
└─────────────────────────────────────────────────────────────────────┘
```

### 2.3 핵심 UX 결정

1. **미리보기는 READ-ONLY**: DB를 변경하지 않음. node_map_master에서 조건에 맞는 레코드만 조회
2. **기본 전체 체크**: 미리보기 시 모든 포인트가 체크된 상태로 표시 (대부분의 경우 전체 적용)
3. **그룹별 색상 라벨**: 어떤 그룹 조건에서 매칭되었는지 표시
4. **검색/필터**: 미리보기 테이블에 태그명 검색 입력창 제공 (수천 개 중 찾기)
5. **"테이블 작성하기"는 유지**: 기존처럼 조건 → 즉시 빌드 (미리보기 없이 빠른 빌드)

---

## 3. 아키텍처 변경

### 3.1 변경 파일

| 파일 | 변경 내용 |
|------|----------|
| `src/Web/wwwroot/index.html` | 미리보기 버튼 + 인라인 미리보기 영역 추가 |
| `src/Web/wwwroot/js/app.js` | `pbPreview()`, `pbCancelPreview()`, `pbApplySelected()`, `pbRenderPreview()` 추가 |
| `src/Web/wwwroot/css/style.css` | `.pb-preview` 스타일 추가 |
| `src/Core/Application/DTOs/ExperionDtos.cs` | `PointBuilderPreviewResultDto` 추가 |
| `src/Core/Application/Interfaces/IExperionServices.cs` | `PreviewRealtimeBuildAsync()` 인터페이스 추가 |
| `src/Infrastructure/Database/ExperionDbContext.cs` | `PreviewRealtimeBuildAsync()` 구현 |
| `src/Web/Controllers/ExperionControllers.cs` | `POST /api/pointbuilder/preview`, `POST /api/pointbuilder/apply` 추가 |

### 3.2 API 엔드포인트

```
GET  /api/pointbuilder/points          — 기존 유지 (realtime_table 조회)
POST /api/pointbuilder/build           — 기존 유지 (즉시 빌드)
POST /api/pointbuilder/preview         — NEW: 조건에 맞는 포인트 조회 (DB 변경 없음)
POST /api/pointbuilder/apply           — NEW: 선택된 포인트만 realtime_table에 적용
POST /api/pointbuilder/add             — 기존 유지 (수동 추가)
DELETE /api/pointbuilder/{id}          — 기존 유지 (삭제)
```

### 3.3 데이터 흐름

```
┌────────────────────────────────────────────────────────────────────┐
│  [미리보기] 클릭                                                     │
│  POST /api/pointbuilder/preview ← groups (기존 build와 동일)        │
│  ↓                                                                  │
│  C#: PreviewRealtimeBuildAsync()                                    │
│    → 각 그룹별 node_map_master 쿼리 (기존 BuildRealtimeTableAsync와  │
│      동일한 쿼리 로직, 하지만 TRUNCATE/INSERT 없이 결과만 반환)       │
│    → 중복 제거 (GroupBy nodeId)                                     │
│    → { count, items: [{ nodeId, tagName, name, dataType, group }] } │
│  ↓                                                                  │
│  JS: pbRenderPreview() — 체크박스 테이블 렌더링                     │
│  ↓                                                                  │
│  사용자가 체크/해제 → [선택된 N개 적용하기] 클릭                      │
│  ↓                                                                  │
│  POST /api/pointbuilder/apply ← { selectedNodeIds: [...] }         │
│  ↓                                                                  │
│  C#: ApplySelectedPointsAsync()                                     │
│    → TRUNCATE realtime_table                                         │
│    → selectedNodeIds만 INSERT                                        │
│    → pbRefresh() → 포인트 목록 갱신                                  │
└────────────────────────────────────────────────────────────────────┘
```

---

## 4. 코딩 Todo List

### Step 1: C# DTO — Preview 결과 DTO 추가

**변경 파일:** `src/Core/Application/DTOs/ExperionDtos.cs`

```csharp
public class PointBuilderPreviewItem
{
    public string NodeId    { get; set; } = string.Empty;
    public string TagName   { get; set; } = string.Empty;
    public string Name      { get; set; } = string.Empty;   // pv, op, sp, md 등
    public string DataType  { get; set; } = string.Empty;
    public string Group     { get; set; } = string.Empty;   // 어떤 그룹에서 매칭
}

public class PointBuilderPreviewResult
{
    public int                        Count { get; set; }
    public List<PointBuilderPreviewItem> Items { get; set; } = new();
}
```

**완성 기준:**
- [ ] 두 클래스 추가됨
- [ ] 빌드 컴파일 성공

---

### Step 2: C# Interface — Preview 메서드 추가

**변경 파일:** `src/Core/Application/Interfaces/IExperionServices.cs`

```csharp
Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<(string GroupKey, PointBuilderGroupDto Group)> groups);
Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds);
```

**참고:** `(string GroupKey, PointBuilderGroupDto Group)` 튜플 사용 — Step 3에서 그룹명 태그에 필요.

**완성 기준:**
- [ ] 두 메서드 시그니처 추가됨
- [ ] 빌드 컴파일 성공 (구현체는 다음 Step)

---

### Step 3: C# DB — PreviewRealtimeBuildAsync 구현

**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`

**구현 로직:**
- 기존 `BuildRealtimeTableAsync`와 동일한 쿼리 로직 재사용
- 각 그룹별로 쿼리 실행 → 결과에 `Group` 속성 태그
- `GroupBy(NodeId)` 중복 제거
- TRUNCATE/INSERT 없이 `PointBuilderPreviewResult` 반환

```csharp
public async Task<PointBuilderPreviewResult> PreviewRealtimeBuildAsync(IEnumerable<PointBuilderGroupDto> groups)
{
    var activeGroups = groups.Where(g =>
        g.TagPatterns != null && g.TagPatterns.Count > 0
    ).ToList();

    if (activeGroups.Count == 0)
        return new PointBuilderPreviewResult();

    var allSources = new List<(NodeMapMaster Node, string Group)>();

    foreach (var g in activeGroups)
    {
        var patterns = g.TagPatterns.Where(p => !string.IsNullOrEmpty(p)).ToList();
        var attrs = g.Attributes.Where(a => !string.IsNullOrEmpty(a)).ToList();

        if (patterns.Count == 0) continue;

        var q = _ctx.NodeMapMasters.Where(x => x.Level == 3);

        var patternList = patterns;
        q = q.Where(x => patternList.Any(p => EF.Functions.Like(x.NodeId, p)));

        if (attrs.Count > 0)
        {
            var attrList = attrs;
            q = q.Where(x => attrList.Contains(x.Name));
        }

        if (!string.IsNullOrEmpty(g.DataType))
        {
            var dt = g.DataType;
            q = q.Where(x => x.DataType == dt);
        }

        var sources = await q.ToListAsync();
        var groupName = GetGroupName(g); // Controller1, AnalogMonitor1 등
        foreach (var s in sources)
            allSources.Add((s, groupName));
    }

    var distinct = allSources
        .GroupBy(x => x.Node.NodeId)
        .Select(g => g.First())
        .ToList();

    var items = distinct.Select(x => new PointBuilderPreviewItem
    {
        NodeId   = x.Node.NodeId,
        TagName  = ExtractTagName(x.Node.NodeId),
        Name     = x.Node.Name,
        DataType = x.Node.DataType,
        Group    = x.Group
    }).ToList();

    return new PointBuilderPreviewResult { Count = items.Count, Items = items };
}
```

**문제:** `GetGroupName(g)` — `PointBuilderGroupDto`에는 그룹명이 없음. 해결: 컨트롤러에서 그룹 키와 함께 전달하도록 변경.

**수정안:** 인터페이스 시그니처를 `IEnumerable<(string GroupKey, PointBuilderGroupDto Group)>`로 변경.

**완성 기준:**
- [ ] 쿼리 로직 구현됨 (기존 BuildRealtimeTableAsync와 동일)
- [ ] TRUNCATE/INSERT 없음 (READ-ONLY)
- [ ] 중복 제거 (GroupBy nodeId)
- [ ] 그룹 정보 포함
- [ ] 빌드 컴파일 성공

---

### Step 4: C# DB — ApplySelectedPointsAsync 구현

**변경 파일:** `src/Infrastructure/Database/ExperionDbContext.cs`

```csharp
public async Task<int> ApplySelectedPointsAsync(IEnumerable<string> selectedNodeIds)
{
    var nodeIds = selectedNodeIds.Where(n => !string.IsNullOrEmpty(n)).ToList();
    if (nodeIds.Count == 0) return 0;

    await _ctx.Database.ExecuteSqlRawAsync(
        "TRUNCATE TABLE realtime_table RESTART IDENTITY");

    var points = nodeIds.Select(nodeId => new RealtimePoint
    {
        TagName   = ExtractTagName(nodeId),
        NodeId    = nodeId,
        LiveValue = null,
        Timestamp = DateTime.UtcNow
    }).ToList();

    await _ctx.RealtimePoints.AddRangeAsync(points);
    await _ctx.SaveChangesAsync();

    _logger.LogInformation("[ExperionDb] realtime_table 적용: {Count}건 (선택)", points.Count);
    return points.Count;
}
```

**완성 기준:**
- [ ] TRUNCATE 후 선택된 nodeId만 INSERT
- [ ] 빌드 컴파일 성공

---

### Step 5: C# Controller — preview + apply 엔드포인트 추가

**변경 파일:** `src/Web/Controllers/ExperionControllers.cs`

```csharp
[HttpPost("preview")]
public async Task<IActionResult> Preview([FromBody] PointBuilderBuildDto dto)
{
    var groups = new[]
    {
        ("controller1", dto.Controller1),
        ("analogmon1", dto.AnalogMonitor1),
        ("digital1", dto.Digital1),
        ("digital2", dto.Digital2),
        ("custom", dto.Custom)
    };

    var result = await _dbSvc.PreviewRealtimeBuildAsync(groups);

    return Ok(new
    {
        count = result.Count,
        items = result.Items.Select(i => new
        {
            nodeId = i.NodeId,
            tagName = i.TagName,
            name = i.Name,
            dataType = i.DataType,
            group = i.Group
        })
    });
}

[HttpPost("apply")]
public async Task<IActionResult> Apply([FromBody] PointBuilderApplyDto dto)
{
    if (dto.SelectedNodeIds == null || dto.SelectedNodeIds.Count == 0)
        return BadRequest(new { success = false, message = "선택된 포인트가 없습니다." });

    var count = await _dbSvc.ApplySelectedPointsAsync(dto.SelectedNodeIds);
    return Ok(new { success = true, count, message = $"{count}개 포인트 적용 완료" });
}
```

**추가 DTO:** `ExperionDtos.cs`에
```csharp
public class PointBuilderApplyDto
{
    public List<string> SelectedNodeIds { get; set; } = new();
}
```

**완성 기준:**
- [ ] 두 엔드포인트 추가됨
- [ ] camelCase JSON 응답
- [ ] PointBuilderApplyDto 추가됨
- [ ] 빌드 컴파일 성공

---

### Step 6: HTML — 미리보기 버튼 + 영역 추가

**변경 파일:** `src/Web/wwwroot/index.html`

**6a. 버튼 행 수정 (기존 L600-603):**
```html
<div class="btn-row">
  <button class="btn-b" onclick="pbPreview()">🔍 미리보기</button>
  <button class="btn-a" onclick="pbBuild()">🔨 테이블 작성하기</button>
  <button class="btn-b" onclick="pbRefresh()">📋 테이블 조회</button>
</div>
```

**6b. 미리보기 영역 추가 (조건으로 테이블 작성 card 내부, 버튼 행 바로 아래):**
```html
<div id="pb-preview" class="pb-preview hidden">
  <div class="pb-preview-header">
    <span>미리보기 결과 <span id="pb-preview-count" class="mut">(0개)</span></span>
    <div class="pb-preview-actions">
      <button class="btn-sm btn-b" onclick="pbPreviewSelectAll()">전체 선택</button>
      <button class="btn-sm btn-b" onclick="pbPreviewDeselectAll()">전체 해제</button>
      <button class="btn-sm btn-b" onclick="pbPreviewInvert()">역전</button>
      <span id="pb-preview-selected" class="mut">선택: 0/0</span>
    </div>
  </div>
  <div class="fg" style="margin-bottom:8px">
    <input class="inp" id="pb-preview-search" placeholder="태그명으로 검색..." oninput="pbPreviewFilter()"/>
  </div>
  <div id="pb-preview-table" class="tbl-wrap" style="max-height:420px;overflow:auto"></div>
  <div class="btn-row" style="margin-top:10px;margin-bottom:0">
    <button class="btn-b" onclick="pbCancelPreview()">취소</button>
    <button class="btn-a" id="pb-apply-btn" onclick="pbApplySelected()">✓ 선택된 포인트 적용하기</button>
  </div>
</div>
```

**완성 기준:**
- [ ] 미리보기 버튼 추가됨
- [ ] 미리보기 영역 HTML 추가됨 (hidden 기본)
- [ ] 전체 선택/해제/역전 버튼 포함
- [ ] 검색 입력창 포함
- [ ] 취소 + 적용 버튼 포함
- [ ] HTML 유효성 검사 통과

---

### Step 7: CSS — 미리보기 스타일 추가

**변경 파일:** `src/Web/wwwroot/css/style.css`

```css
.pb-preview {
  background: var(--s3);
  border: 1px solid var(--bd);
  border-radius: var(--r);
  padding: 14px 16px;
  margin-top: 10px;
}

.pb-preview-header {
  display: flex;
  justify-content: space-between;
  align-items: center;
  margin-bottom: 4px;
  font-weight: 600;
  font-size: 13px;
}

.pb-preview-actions {
  display: flex;
  gap: 6px;
  align-items: center;
}

.pb-preview table th:first-child,
.pb-preview table td:first-child {
  width: 36px;
  text-align: center;
}

.pb-preview table input[type="checkbox"] {
  cursor: pointer;
  width: 15px;
  height: 15px;
}

.pb-preview .group-badge {
  display: inline-block;
  font-size: 10px;
  padding: 1px 6px;
  border-radius: 3px;
  background: var(--ag);
  color: var(--a);
  font-weight: 600;
}

@media (max-width: 900px) {
  .pb-preview-header { flex-direction: column; gap: 8px; align-items: flex-start; }
  .pb-preview-actions { flex-wrap: wrap; }
}
```

**완성 기준:**
- [ ] `.pb-preview` 스타일 정의됨
- [ ] 체크박스 컬럼 너비 조정
- [ ] 그룹 배지 스타일
- [ ] 반응형 대응
- [ ] 기존 스타일과 충돌 없음

---

### Step 8: JS — 미리보기 로직 추가

**변경 파일:** `src/Web/wwwroot/js/app.js`

**8a. 전역 변수 (L575 근처):**
```javascript
let pbPreviewData = [];  // 미리보기 원본 데이터
```

**8b. 새 함수 추가:**

```javascript
async function pbPreview() {
  const groups = {};
  for (const gk of PB_GROUPS) {
    const gd = pbCollectGroupData(gk);
    if (gd.tagPatterns.length > 0) {
      groups[gk] = gd;
    }
  }

  const activeKeys = Object.keys(groups);
  if (activeKeys.length === 0) {
    const logEl = document.getElementById('pb-build-log');
    logEl.classList.remove('hidden');
    logEl.innerHTML = '<div class="ll err">⚠️ 태그명 패턴을 최소 1개 입력하세요.</div>';
    return;
  }

  setGlobal('busy', '미리보기 조회 중');
  try {
    const d = await api('POST', '/api/pointbuilder/preview', groups);
    pbPreviewData = (d.items || []).map((item, idx) => ({ ...item, selected: true, idx }));
    document.getElementById('pb-preview-count').textContent = `(${d.count}개)`;
    document.getElementById('pb-preview').classList.remove('hidden');
    pbRenderPreview(pbPreviewData);
    setGlobal('ok', `미리보기: ${d.count}개 포인트`);
  } catch (e) {
    setGlobal('err', '미리보기 실패');
  }
}

function pbRenderPreview(data) {
  const el = document.getElementById('pb-preview-table');
  const filtered = pbGetFilteredPreview();
  const pts = filtered.length > 0 ? filtered : data;

  if (pts.length === 0) {
    el.innerHTML = '<div style="padding:20px;color:var(--t2)">조건에 맞는 포인트가 없습니다.</div>';
    pbUpdatePreviewCount();
    return;
  }

  el.innerHTML = `
    <table>
      <thead>
        <tr>
          <th><input type="checkbox" onchange="pbPreviewToggleAll(this.checked)" title="전체 선택/해제"/></th>
          <th>ID</th>
          <th>TagName</th>
          <th>NodeType</th>
          <th>DataType</th>
          <th>Group</th>
        </tr>
      </thead>
      <tbody>
        ${pts.map((p, i) => `
          <tr style="${!p.selected ? 'opacity:0.5' : ''}">
            <td><input type="checkbox" ${p.selected ? 'checked' : ''} onchange="pbPreviewToggleItem(${p.idx})"/></td>
            <td class="mut">${i + 1}</td>
            <td style="font-weight:600">${esc((p?.tagName)?.toUpperCase() || '')}</td>
            <td>${esc(p?.name || '')}</td>
            <td class="mut">${esc(p?.dataType || '')}</td>
            <td><span class="group-badge">${esc(p?.group || '')}</span></td>
          </tr>
        `).join('')}
      </tbody>
    </table>
  `;
  pbUpdatePreviewCount();
}

function pbPreviewToggleItem(idx) {
  pbPreviewData[idx].selected = !pbPreviewData[idx].selected;
  pbUpdatePreviewCount();
}

function pbPreviewToggleAll(checked) {
  pbPreviewData.forEach((item, idx) => {
    const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
    if (searchVal) {
      const filtered = pbGetFilteredPreview();
      if (filtered.includes(item)) {
        item.selected = checked;
      }
    } else {
      item.selected = checked;
    }
  });
  pbRenderPreview(pbPreviewData);
}

function pbPreviewSelectAll() {
  pbPreviewData.forEach(p => p.selected = true);
  pbRenderPreview(pbPreviewData);
}

function pbPreviewDeselectAll() {
  pbPreviewData.forEach(p => p.selected = false);
  pbRenderPreview(pbPreviewData);
}

function pbPreviewInvert() {
  pbPreviewData.forEach(p => p.selected = !p.selected);
  pbRenderPreview(pbPreviewData);
}

function pbGetFilteredPreview() {
  const searchVal = document.getElementById('pb-preview-search')?.value?.toLowerCase();
  if (!searchVal) return [];
  return pbPreviewData.filter(p =>
    (p.tagName || '').toLowerCase().includes(searchVal) ||
    (p.nodeId || '').toLowerCase().includes(searchVal) ||
    (p.name || '').toLowerCase().includes(searchVal)
  );
}

function pbPreviewFilter() {
  pbRenderPreview(pbPreviewData);
}

function pbUpdatePreviewCount() {
  const selected = pbPreviewData.filter(p => p.selected).length;
  const total = pbPreviewData.length;
  document.getElementById('pb-preview-selected').textContent = `선택: ${selected}/${total}`;
}

function pbCancelPreview() {
  document.getElementById('pb-preview').classList.add('hidden');
  pbPreviewData = [];
}

async function pbApplySelected() {
  const selected = pbPreviewData.filter(p => p.selected).map(p => p.nodeId);
  if (selected.length === 0) {
    setGlobal('err', '적용할 포인트를 선택하세요.');
    return;
  }

  setGlobal('busy', `${selected.length}개 포인트 적용 중`);
  try {
    const d = await api('POST', '/api/pointbuilder/apply', { selectedNodeIds: selected });
    setGlobal(d.success ? 'ok' : 'err', d.success ? `${d.count}개 포인트 적용 완료` : '적용 실패');
    if (d.success) {
      pbCancelPreview();
      await pbRefresh();
    }
  } catch (e) {
    setGlobal('err', '적용 오류');
  }
}
```

**완성 기준:**
- [ ] `pbPreviewData` 전역 변수 추가됨
- [ ] `pbPreview()` 함수 추가됨
- [ ] `pbRenderPreview()` 함수 추가됨
- [ ] `pbPreviewToggleItem()`, `pbPreviewToggleAll()` 추가됨
- [ ] `pbPreviewSelectAll()`, `pbPreviewDeselectAll()`, `pbPreviewInvert()` 추가됨
- [ ] `pbGetFilteredPreview()`, `pbPreviewFilter()` 검색 기능 추가됨
- [ ] `pbUpdatePreviewCount()` 카운트 업데이트
- [ ] `pbCancelPreview()` 취소
- [ ] `pbApplySelected()` 선택된 것만 적용
- [ ] `pbPreviewData`에 `idx` 속성 부여 (indexOf 버그 방지)
- [ ] 콘솔 에러 없음

---

### Step 9: 빌드 및 검증

**작업 내용:**
1. `dotnet build src/Web/ExperionCrawler.csproj`
2. 빌드 성공 (0 error, 0 warning)
3. 브라우저에서 테스트:
   - 조건 입력 → 미리보기 → 결과 확인
   - 체크/해제/역전 동작
   - 검색 필터 동작
   - 선택된 것만 적용 → 포인트 목록 갱신
   - 기존 "테이블 작성하기" 버튼도 정상 동작

**완성 기준:**
- [ ] 빌드 성공 (0 error, 0 warning)
- [ ] 미리보기 버튼 클릭 시 결과 표시
- [ ] 전체 선택/해제/역전 정상
- [ ] 검색 필터 정상
- [ ] 선택 적용 후 포인트 목록 갱신
- [ ] 기존 빌드 버튼 영향 없음
- [ ] 기존 pbRefresh/pbDelete 영향 없음

---

## 5. 진행 상태 추적

| Step | 상태 | 비고 |
|------|------|------|
| 1. DTO | ⬜ 미완료 | PointBuilderPreviewItem, PointBuilderPreviewResult, PointBuilderApplyDto |
| 2. Interface | ⬜ 미완료 | PreviewRealtimeBuildAsync, ApplySelectedPointsAsync |
| 3. DB Preview | ⬜ 미완료 | READ-ONLY 쿼리 |
| 4. DB Apply | ⬜ 미완료 | 선택된 nodeId만 TRUNCATE + INSERT |
| 5. Controller | ⬜ 미완료 | preview + apply 엔드포인트 |
| 6. HTML | ⬜ 미완료 | 미리보기 버튼 + 영역 |
| 7. CSS | ⬜ 미완료 | .pb-preview 스타일 |
| 8. JS | ⬜ 미완료 | 미리보기 로직 (중복 pbBuild 이미 삭제 완료) |
| 9. 빌드 | ⬜ 미완료 | 검증 |

---

## 6. 부가 사항

### 6.1 기존 버그 수정 (이미 완료)

- **중복 `pbBuild()` 함수 (app.js L671-686):** ✅ 이미 삭제 완료 (코딩.md 반영 시)
- **`pbRender()` 테이블 컬럼 불일치 (app.js L660-662):** ✅ 컬럼 순서 확인 완료

### 6.2 향후 개선 (별도 작업)

- 그룹별 색상 배지 (컨트롤러=파랑, 아날로그=초록, 디지털=주황)
- 미리보기 결과 Excel/PDF 내보내기
- 최근 미리보기 조건 저장 (localStorage)
- 페이지네이션 (1000개 이상 시)
