Files
ExperionCrawler/plans/포인트빌더-개선방안-코딩2.md
windpacer 7330711499 chore: 프로젝트 파일 구조 정리 - 루트 파일 폴더별 이동, 테스트/구버전 삭제
루트 파일 정리:
- DXF/P&ID 관련 → dxf-graph/
- fastTable 관련 → fastTable/
- plan/ → plans/ 통합 (최신 버전 유지)
- 테스트 출력 파일, 구버전 프로젝트 삭제
- 불필요한 루트 문서 삭제
2026-05-10 17:39:58 +09:00

25 KiB

포인트빌더 개선 방안 - 코딩 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

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

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 반환
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

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

[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

public class PointBuilderApplyDto
{
    public List<string> SelectedNodeIds { get; set; } = new();
}

완성 기준:

  • 두 엔드포인트 추가됨
  • camelCase JSON 응답
  • PointBuilderApplyDto 추가됨
  • 빌드 컴파일 성공

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

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

6a. 버튼 행 수정 (기존 L600-603):

<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 내부, 버튼 행 바로 아래):

<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

.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 근처):

let pbPreviewData = [];  // 미리보기 원본 데이터

8b. 새 함수 추가:

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() 선택된 것만 적용
  • pbPreviewDataidx 속성 부여 (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개 이상 시)