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:
@@ -27,6 +27,8 @@ public record CreateEquipmentRequest(
|
|||||||
string? Role = null,
|
string? Role = null,
|
||||||
string? FromTag = null,
|
string? FromTag = null,
|
||||||
string? ToTag = null,
|
string? ToTag = null,
|
||||||
|
string? FromAt = null,
|
||||||
|
string? ToAt = null,
|
||||||
string? TagClass = null);
|
string? TagClass = null);
|
||||||
|
|
||||||
public record UpdateEquipmentRequest(
|
public record UpdateEquipmentRequest(
|
||||||
@@ -38,4 +40,7 @@ public record UpdateEquipmentRequest(
|
|||||||
string? Role = null,
|
string? Role = null,
|
||||||
string? FromTag = null,
|
string? FromTag = null,
|
||||||
string? ToTag = null,
|
string? ToTag = null,
|
||||||
string? TagClass = null);
|
string? FromAt = null,
|
||||||
|
string? ToAt = null,
|
||||||
|
string? TagClass = null,
|
||||||
|
string? SubArea = null);
|
||||||
|
|||||||
@@ -408,12 +408,12 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
List<PidEquipment> items;
|
List<PidEquipment> items;
|
||||||
if (sortBy == "tagName")
|
if (sortBy == "tagName")
|
||||||
{
|
{
|
||||||
// DB에서 기본 정렬로 가져온 뒤 C#에서 natural sort
|
// DB 전체 로드 후 C#에서 natural sort (prefix 알파 → 숫자 정수값 → suffix 알파)
|
||||||
var all = await q.OrderBy(e => e.TagNo.Length).ThenBy(e => e.TagNo).ToListAsync();
|
var all = await q.ToListAsync();
|
||||||
if (sortDesc)
|
if (sortDesc)
|
||||||
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ThenByDescending(e => e.TagNo.ToLowerInvariant()).ToList();
|
all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ToList();
|
||||||
else
|
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();
|
items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList();
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
@@ -421,15 +421,39 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
items = await q.OrderByDescending(e => e.ExtractedAt)
|
items = await q.OrderByDescending(e => e.ExtractedAt)
|
||||||
.Skip((page - 1) * pageSize).Take(pageSize).ToListAsync();
|
.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);
|
return (total, items);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string TagSortKey(string tagNo)
|
private static string TagSortKey(string tagNo)
|
||||||
{
|
{
|
||||||
// prefix (첫 digit 앞까지의 문자) + 길이 + 원본 순으로 정렬 가능한 키 생성
|
// (알파 prefix)(숫자부)(알파 suffix) 분해 → 숫자를 정수로 비교
|
||||||
var m = Regex.Match(tagNo, @"^([A-Za-z-]+)");
|
// TI-6111B → "ti-" + 0000006111 + "b"
|
||||||
var prefix = m.Success ? m.Groups[1].Value.ToLowerInvariant() : tagNo.ToLowerInvariant();
|
// TI-10103 → "ti-" + 0000010103 + ""
|
||||||
return $"{prefix}\x00{tagNo.Length:D4}\x00{tagNo.ToLowerInvariant()}";
|
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)
|
public async Task<PidEquipment?> GetByIdAsync(long id)
|
||||||
@@ -455,6 +479,8 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
Role = request.Role,
|
Role = request.Role,
|
||||||
FromTag = request.FromTag,
|
FromTag = request.FromTag,
|
||||||
ToTag = request.ToTag,
|
ToTag = request.ToTag,
|
||||||
|
FromAt = request.FromAt,
|
||||||
|
ToAt = request.ToAt,
|
||||||
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
|
TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false),
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
Confidence = 1.0,
|
Confidence = 1.0,
|
||||||
@@ -481,6 +507,8 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
if (request.Role != null) e.Role = request.Role;
|
if (request.Role != null) e.Role = request.Role;
|
||||||
if (request.FromTag != null) e.FromTag = request.FromTag;
|
if (request.FromTag != null) e.FromTag = request.FromTag;
|
||||||
if (request.ToTag != null) e.ToTag = request.ToTag;
|
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.TagClass != null) e.TagClass = request.TagClass;
|
||||||
if (request.Category != null || request.TagDcs.HasValue)
|
if (request.Category != null || request.TagDcs.HasValue)
|
||||||
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
|
e.TagClass = ClassifyTagClass(e.Category, e.TagDcs);
|
||||||
@@ -488,6 +516,19 @@ public class PidExtractorService : IPidExtractorService
|
|||||||
e.ConnectionLocked = true;
|
e.ConnectionLocked = true;
|
||||||
e.UpdatedAt = DateTime.UtcNow;
|
e.UpdatedAt = DateTime.UtcNow;
|
||||||
await _dbContext.SaveChangesAsync();
|
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;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -116,4 +116,8 @@ public class PidEquipment
|
|||||||
[MaxLength(255)]
|
[MaxLength(255)]
|
||||||
[Column("drawing_file")]
|
[Column("drawing_file")]
|
||||||
public string? DrawingFile { get; set; }
|
public string? DrawingFile { get; set; }
|
||||||
|
|
||||||
|
// ── UI 전용 (DB 컬럼 없음, API 응답 시 tag_metadata LEFT JOIN) ──
|
||||||
|
[System.ComponentModel.DataAnnotations.Schema.NotMapped]
|
||||||
|
public string? SubArea { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -131,7 +131,10 @@ public class PidController : ControllerBase
|
|||||||
tagDcs = e.TagDcs,
|
tagDcs = e.TagDcs,
|
||||||
role = e.Role,
|
role = e.Role,
|
||||||
fromTag = e.FromTag,
|
fromTag = e.FromTag,
|
||||||
|
fromAt = e.FromAt,
|
||||||
toTag = e.ToTag,
|
toTag = e.ToTag,
|
||||||
|
toAt = e.ToAt,
|
||||||
|
subArea = e.SubArea,
|
||||||
posX = e.PosX,
|
posX = e.PosX,
|
||||||
posY = e.PosY,
|
posY = e.PosY,
|
||||||
drawingFile = e.DrawingFile
|
drawingFile = e.DrawingFile
|
||||||
|
|||||||
@@ -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; }
|
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); }
|
thead { background: var(--s2); }
|
||||||
th {
|
th {
|
||||||
|
|||||||
@@ -180,7 +180,7 @@ async function pidLoadTable(page = 1) {
|
|||||||
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
|
const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : '';
|
||||||
const si = document.getElementById('pid-sort-indicator');
|
const si = document.getElementById('pid-sort-indicator');
|
||||||
if (si) si.textContent = pidSortBy === 'tagName' ? (pidSortDesc ? '▼' : '▲') : '';
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`);
|
const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`);
|
||||||
@@ -190,7 +190,7 @@ async function pidLoadTable(page = 1) {
|
|||||||
pidLastResult = data;
|
pidLastResult = data;
|
||||||
|
|
||||||
if (!data.items || data.items.length === 0) {
|
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 = '';
|
document.getElementById('pid-pagination').innerHTML = '';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -222,10 +222,13 @@ async function pidLoadTable(page = 1) {
|
|||||||
</td>
|
</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="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="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="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>
|
<td>
|
||||||
${item.experionTagId
|
${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>`
|
: `<button class="btn-sm btn-b" onclick="pidOpenMapping(${item.id}, this)">매핑</button>`
|
||||||
}
|
}
|
||||||
</td>
|
</td>
|
||||||
@@ -240,7 +243,7 @@ async function pidLoadTable(page = 1) {
|
|||||||
|
|
||||||
pidRenderPagination(data.total, page);
|
pidRenderPagination(data.total, page);
|
||||||
} catch (e) {
|
} 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>
|
||||||
<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="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="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="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></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-a" onclick="pidCreateRow(this)">💾</button></td>
|
||||||
<td style="text-align:center"><button class="btn-sm btn-b" onclick="this.closest('tr').remove()">✕</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 { category, tagDcs } = pidResolveCat(vcat);
|
||||||
const body = { category: category || null, tagDcs };
|
const body = { category: category || null, tagDcs };
|
||||||
for (const inp of inputs) {
|
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 {
|
try {
|
||||||
const res = await fetch(`/api/pid/${id}`, {
|
const res = await fetch(`/api/pid/${id}`, {
|
||||||
@@ -664,7 +671,7 @@ async function pidOpenMapping(id, btn) {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
btn.outerHTML = `<span class="badge ok">✅ ${esc((data.tagName||'').toUpperCase())}</span>`;
|
btn.textContent = '✅';
|
||||||
} catch {
|
} catch {
|
||||||
btn.textContent = '❌';
|
btn.textContent = '❌';
|
||||||
btn.title = '오류';
|
btn.title = '오류';
|
||||||
|
|||||||
@@ -84,22 +84,41 @@
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
<div id="pid-table-container" style="overflow-x:auto">
|
<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>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
<th style="width:60px">ID</th>
|
<th>ID</th>
|
||||||
<th style="cursor:pointer;user-select:none" onclick="pidToggleSort('tagName')">
|
<th style="cursor:pointer;user-select:none" onclick="pidToggleSort('tagName')">
|
||||||
태그번호 <span id="pid-sort-indicator" style="font-size:11px;color:var(--t2)"></span>
|
태그번호 <span id="pid-sort-indicator" style="font-size:11px;color:var(--t2)"></span>
|
||||||
</th>
|
</th>
|
||||||
<th>장비명</th>
|
<th>장비명</th>
|
||||||
<th>유형</th>
|
<th>유형</th>
|
||||||
<th>카테고리</th>
|
<th>카테고리</th>
|
||||||
<th>Role</th>
|
<th>ROLE</th>
|
||||||
<th>From</th>
|
<th>FROM</th>
|
||||||
<th>To</th>
|
<th>FROM_at</th>
|
||||||
<th style="width:80px">매핑</th>
|
<th>TO</th>
|
||||||
<th style="width:60px">저장</th>
|
<th>TO_at</th>
|
||||||
<th style="width:40px">삭제</th>
|
<th>SUB_AREA</th>
|
||||||
|
<th>매핑</th>
|
||||||
|
<th>저장</th>
|
||||||
|
<th>삭제</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pid-table-body"></tbody>
|
<tbody id="pid-table-body"></tbody>
|
||||||
|
|||||||
Reference in New Issue
Block a user