feat(pid-ui): 추출결과 pane 컬럼 추가 + 정렬 수정 + sub_area 편집

- FROM_at / TO_at / SUB_AREA 컬럼 추가 (헤더·행 렌더·추가행 입력란)
- SUB_AREA: tag_metadata upsert/delete로 직접 편집 가능 (빈 문자열 = 삭제)
- natural sort 수정: prefix 알파 → 숫자 정수값 → suffix 알파
  (기존 TagNo.Length 기준 그루핑 제거 → TI-9217 다음 TI-10103 올바르게 정렬)
- 매핑 완료 시 태그명 badge 제거 →  버튼만 표시
- colgroup 퍼센트 비율로 컬럼 폭 고정 (table-layout:fixed)
- pid-table td/th overflow:hidden + text-overflow:ellipsis 적용

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
windpacer
2026-05-29 09:47:26 +09:00
parent 8f5dabbbc7
commit f972c66810
7 changed files with 103 additions and 23 deletions

View File

@@ -27,6 +27,8 @@ public record CreateEquipmentRequest(
string? Role = null,
string? FromTag = null,
string? ToTag = null,
string? FromAt = null,
string? ToAt = null,
string? TagClass = null);
public record UpdateEquipmentRequest(
@@ -38,4 +40,7 @@ public record UpdateEquipmentRequest(
string? Role = null,
string? FromTag = null,
string? ToTag = null,
string? TagClass = null);
string? FromAt = null,
string? ToAt = null,
string? TagClass = null,
string? SubArea = null);

View File

@@ -408,12 +408,12 @@ public class PidExtractorService : IPidExtractorService
List<PidEquipment> items;
if (sortBy == "tagName")
{
// DB에서 기본 정렬로 가져온 뒤 C#에서 natural sort
var all = await q.OrderBy(e => e.TagNo.Length).ThenBy(e => e.TagNo).ToListAsync();
// DB 전체 로드 후 C#에서 natural sort (prefix 알파 → 숫자 정수값 → suffix 알파)
var all = await q.ToListAsync();
if (sortDesc)
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ThenByDescending(e => e.TagNo.ToLowerInvariant()).ToList();
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ToList();
else
all = all.OrderBy(e => TagSortKey(e.TagNo)).ThenBy(e => e.TagNo.ToLowerInvariant()).ToList();
all = all.OrderBy(e => TagSortKey(e.TagNo)).ToList();
items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
}
else
@@ -421,15 +421,39 @@ public class PidExtractorService : IPidExtractorService
items = await q.OrderByDescending(e => e.ExtractedAt)
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
}
// batch-load sub_area from tag_metadata
if (items.Count > 0)
{
var tagNos = items.Select(e => e.TagNo.ToLowerInvariant()).ToHashSet();
var subAreas = await _dbContext.TagMetadata
.Where(m => tagNos.Contains(m.BaseTag) && m.Attribute == "sub_area")
.Select(m => new { m.BaseTag, m.Value })
.ToListAsync();
var subMap = subAreas.ToDictionary(sa => sa.BaseTag, sa => sa.Value);
foreach (var e in items)
{
if (subMap.TryGetValue(e.TagNo.ToLowerInvariant(), out var sa))
e.SubArea = sa;
}
}
return (total, items);
}
private static string TagSortKey(string tagNo)
{
// prefix (첫 digit 앞까지의 문자) + 길이 + 원본 순으로 정렬 가능한 키 생성
var m = Regex.Match(tagNo, @"^([A-Za-z-]+)");
var prefix = m.Success ? m.Groups[1].Value.ToLowerInvariant() : tagNo.ToLowerInvariant();
return $"{prefix}\x00{tagNo.Length:D4}\x00{tagNo.ToLowerInvariant()}";
// (알파 prefix)(숫자부)(알파 suffix) 분해 → 숫자를 정수로 비교
// TI-6111B → "ti-" + 0000006111 + "b"
// TI-10103 → "ti-" + 0000010103 + ""
var tag = tagNo.ToLowerInvariant();
var m = Regex.Match(tag, @"^([^\d]+)(\d+)([a-z]*)$");
if (!m.Success)
return tag;
var prefix = m.Groups[1].Value;
var num = long.TryParse(m.Groups[2].Value, out var n) ? n : 0L;
var suffix = m.Groups[3].Value;
return $"{prefix}\x00{num:D12}\x00{suffix}";
}
public async Task<PidEquipment?> GetByIdAsync(long id)
@@ -455,6 +479,8 @@ public class PidExtractorService : IPidExtractorService
Role = request.Role,
FromTag = request.FromTag,
ToTag = request.ToTag,
FromAt = request.FromAt,
ToAt = request.ToAt,
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
IsActive = true,
Confidence = 1.0,
@@ -481,6 +507,8 @@ public class PidExtractorService : IPidExtractorService
if (request.Role != null) e.Role = request.Role;
if (request.FromTag != null) e.FromTag = request.FromTag;
if (request.ToTag != null) e.ToTag = request.ToTag;
if (request.FromAt != null) e.FromAt = request.FromAt;
if (request.ToAt != null) e.ToAt = request.ToAt;
if (request.TagClass != null) e.TagClass = request.TagClass;
if (request.Category != null || request.TagDcs.HasValue)
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
@@ -488,6 +516,19 @@ public class PidExtractorService : IPidExtractorService
e.ConnectionLocked = true;
e.UpdatedAt = DateTime.UtcNow;
await _dbContext.SaveChangesAsync();
if (request.SubArea != null)
{
var baseTag = e.TagNo.ToLowerInvariant();
if (string.IsNullOrWhiteSpace(request.SubArea))
await _dbContext.Database.ExecuteSqlRawAsync(
"DELETE FROM tag_metadata WHERE base_tag = {0} AND attribute = 'sub_area'", baseTag);
else
await _dbContext.Database.ExecuteSqlRawAsync(
@"INSERT INTO tag_metadata (base_tag, attribute, value)
VALUES ({0}, 'sub_area', {1})
ON CONFLICT (base_tag, attribute) DO UPDATE SET value = EXCLUDED.value, loaded_at = NOW()",
baseTag, request.SubArea.Trim());
}
return true;
}

View File

@@ -116,4 +116,8 @@ public class PidEquipment
[MaxLength(255)]
[Column("drawing_file")]
public string? DrawingFile { get; set; }
// ── UI 전용 (DB 컬럼 없음, API 응답 시 tag_metadata LEFT JOIN) ──
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
public string? SubArea { get; set; }
}

View File

@@ -131,7 +131,10 @@ public class PidController : ControllerBase
tagDcs = e.TagDcs,
role = e.Role,
fromTag = e.FromTag,
fromAt = e.FromAt,
toTag = e.ToTag,
toAt = e.ToAt,
subArea = e.SubArea,
posX = e.PosX,
posY = e.PosY,
drawingFile = e.DrawingFile

View File

@@ -366,6 +366,7 @@ html, body { height: 100%; background: var(--s0); color: var(--t1); font-family:
}
table { width: 100%; border-collapse: collapse; font-family: var(--fm); font-size: 12px; }
#pid-table td, #pid-table th { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
thead { background: var(--s2); }
th {

View File

@@ -180,7 +180,7 @@ async function pidLoadTable(page = 1) {
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
const si = document.getElementById('pid-sort-indicator');
if (si) si.textContent = pidSortBy === 'tagName' ? (pidSortDesc ? '▼' : '▲') : '';
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">로딩 중...</td></tr>';
tbody.innerHTML = '<tr><td colspan="14" style="text-align:center;padding:20px">로딩 중...</td></tr>';
try {
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`);
@@ -190,7 +190,7 @@ async function pidLoadTable(page = 1) {
pidLastResult = data;
if (!data.items || data.items.length === 0) {
tbody.innerHTML = '<tr><td colspan="11" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
tbody.innerHTML = '<tr><td colspan="14" style="text-align:center;padding:20px">데이터가 없습니다</td></tr>';
document.getElementById('pid-pagination').innerHTML = '';
return;
}
@@ -222,10 +222,13 @@ async function pidLoadTable(page = 1) {
</td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="role" value="${esc(item.role) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromTag" value="${esc(item.fromTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="fromAt" value="${esc(item.fromAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toTag" value="${esc(item.toTag) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="toAt" value="${esc(item.toAt) || ''}" style="width:100%"/></td>
<td><input class="inp pid-edit" data-id="${item.id}" data-field="subArea" value="${esc(item.subArea) || ''}" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
<td>
${item.experionTagId
? `<span class="badge ok">✅ ${esc((item.experionTagName || '').toUpperCase())}</span>`
? `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">✅</button>`
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
}
</td>
@@ -240,7 +243,7 @@ async function pidLoadTable(page = 1) {
pidRenderPagination(data.total, page);
} catch (e) {
tbody.innerHTML = `<tr><td colspan="11" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
tbody.innerHTML = `<tr><td colspan="14" style="text-align:center;padding:20px;color:var(--red)">오류: ${e.message}</td></tr>`;
}
}
@@ -298,7 +301,10 @@ function pidShowAddRow() {
</td>
<td><input class="inp pid-add-input" data-field="role" placeholder="Role" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="fromTag" placeholder="From" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="fromAt" placeholder="From_at" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="toTag" placeholder="To" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="toAt" placeholder="To_at" style="width:100%"/></td>
<td><input class="inp pid-add-input" data-field="subArea" placeholder="예: P9-1" style="width:100%;font-family:var(--mono)"/></td>
<td></td>
<td style="text-align:center"><button class="btn-sm btn-a" onclick="pidCreateRow(this)">💾</button></td>
<td style="text-align:center"><button class="btn-sm btn-b" onclick="this.closest('tr').remove()">✕</button></td>
@@ -343,7 +349,8 @@ async function pidSaveRow(id) {
const { category, tagDcs } = pidResolveCat(vcat);
const body = { category: category || null, tagDcs };
for (const inp of inputs) {
body[inp.dataset.field] = inp.value || null;
// subArea: 빈 문자열도 전송 (= tag_metadata에서 삭제)
body[inp.dataset.field] = inp.dataset.field === 'subArea' ? inp.value : (inp.value || null);
}
try {
const res = await fetch(`/api/pid/${id}`, {
@@ -664,7 +671,7 @@ async function pidOpenMapping(id, btn) {
return;
}
const data = await res.json();
btn.outerHTML = `<span class="badge ok">✅ ${esc((data.tagName||'').toUpperCase())}</span>`;
btn.textContent = '✅';
} catch {
btn.textContent = '❌';
btn.title = '오류';

View File

@@ -84,22 +84,41 @@
</select>
</div>
<div id="pid-table-container" style="overflow-x:auto">
<table class="table" id="pid-table">
<table class="table" id="pid-table" style="table-layout:fixed;width:100%">
<colgroup>
<col style="width:4%"/> <!-- ID -->
<col style="width:9%"/> <!-- 태그번호 -->
<col style="width:10%"/> <!-- 장비명 -->
<col style="width:5%"/> <!-- 유형 -->
<col style="width:10%"/> <!-- 카테고리 -->
<col style="width:13%"/> <!-- ROLE -->
<col style="width:7%"/> <!-- FROM -->
<col style="width:8%"/> <!-- FROM_at -->
<col style="width:7%"/> <!-- TO -->
<col style="width:8%"/> <!-- TO_at -->
<col style="width:7%"/> <!-- SUB_AREA -->
<col style="width:4%"/> <!-- 매핑 -->
<col style="width:4%"/> <!-- 저장 -->
<col style="width:4%"/> <!-- 삭제 -->
</colgroup>
<thead>
<tr>
<th style="width:60px">ID</th>
<th>ID</th>
<th style="cursor:pointer;user-select:none" onclick="pidToggleSort('tagName')">
태그번호 <span id="pid-sort-indicator" style="font-size:11px;color:var(--t2)"></span>
</th>
<th>장비명</th>
<th>유형</th>
<th>카테고리</th>
<th>Role</th>
<th>From</th>
<th>To</th>
<th style="width:80px">매핑</th>
<th style="width:60px">저장</th>
<th style="width:40px">삭제</th>
<th>ROLE</th>
<th>FROM</th>
<th>FROM_at</th>
<th>TO</th>
<th>TO_at</th>
<th>SUB_AREA</th>
<th>매핑</th>
<th>저장</th>
<th>삭제</th>
</tr>
</thead>
<tbody id="pid-table-body"></tbody>