From f972c668104a790956f0f6bf9f473c5cfa2b28f0 Mon Sep 17 00:00:00 2001 From: windpacer Date: Fri, 29 May 2026 09:47:26 +0900 Subject: [PATCH] =?UTF-8?q?feat(pid-ui):=20=EC=B6=94=EC=B6=9C=EA=B2=B0?= =?UTF-8?q?=EA=B3=BC=20pane=20=EC=BB=AC=EB=9F=BC=20=EC=B6=94=EA=B0=80=20+?= =?UTF-8?q?=20=EC=A0=95=EB=A0=AC=20=EC=88=98=EC=A0=95=20+=20sub=5Farea=20?= =?UTF-8?q?=ED=8E=B8=EC=A7=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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 --- src/Core/Application/DTOs/PidEquipmentDto.cs | 7 ++- .../Services/PidExtractorService.cs | 57 ++++++++++++++++--- src/Core/Domain/Entities/PidEquipment.cs | 4 ++ src/Web/Controllers/PidController.cs | 3 + src/Web/wwwroot/css/style.css | 1 + src/Web/wwwroot/js/pid.js | 19 +++++-- src/Web/wwwroot/panes/pid.html | 35 +++++++++--- 7 files changed, 103 insertions(+), 23 deletions(-) diff --git a/src/Core/Application/DTOs/PidEquipmentDto.cs b/src/Core/Application/DTOs/PidEquipmentDto.cs index 614fb8f..ffe118a 100644 --- a/src/Core/Application/DTOs/PidEquipmentDto.cs +++ b/src/Core/Application/DTOs/PidEquipmentDto.cs @@ -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); diff --git a/src/Core/Application/Services/PidExtractorService.cs b/src/Core/Application/Services/PidExtractorService.cs index 1f98a1b..678c5e4 100644 --- a/src/Core/Application/Services/PidExtractorService.cs +++ b/src/Core/Application/Services/PidExtractorService.cs @@ -408,12 +408,12 @@ public class PidExtractorService : IPidExtractorService List 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 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; } diff --git a/src/Core/Domain/Entities/PidEquipment.cs b/src/Core/Domain/Entities/PidEquipment.cs index 7d97654..52586fe 100644 --- a/src/Core/Domain/Entities/PidEquipment.cs +++ b/src/Core/Domain/Entities/PidEquipment.cs @@ -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; } } diff --git a/src/Web/Controllers/PidController.cs b/src/Web/Controllers/PidController.cs index f502d59..b2d1d7b 100644 --- a/src/Web/Controllers/PidController.cs +++ b/src/Web/Controllers/PidController.cs @@ -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 diff --git a/src/Web/wwwroot/css/style.css b/src/Web/wwwroot/css/style.css index 5a24195..c4de6a7 100644 --- a/src/Web/wwwroot/css/style.css +++ b/src/Web/wwwroot/css/style.css @@ -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 { diff --git a/src/Web/wwwroot/js/pid.js b/src/Web/wwwroot/js/pid.js index 0d9d90b..5f62b21 100644 --- a/src/Web/wwwroot/js/pid.js +++ b/src/Web/wwwroot/js/pid.js @@ -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 = '로딩 중...'; + tbody.innerHTML = '로딩 중...'; 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 = '데이터가 없습니다'; + tbody.innerHTML = '데이터가 없습니다'; document.getElementById('pid-pagination').innerHTML = ''; return; } @@ -222,10 +222,13 @@ async function pidLoadTable(page = 1) { + + + ${item.experionTagId - ? `✅ ${esc((item.experionTagName || '').toUpperCase())}` + ? `` : `` } @@ -240,7 +243,7 @@ async function pidLoadTable(page = 1) { pidRenderPagination(data.total, page); } catch (e) { - tbody.innerHTML = `오류: ${e.message}`; + tbody.innerHTML = `오류: ${e.message}`; } } @@ -298,7 +301,10 @@ function pidShowAddRow() { + + + @@ -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 = `✅ ${esc((data.tagName||'').toUpperCase())}`; + btn.textContent = '✅'; } catch { btn.textContent = '❌'; btn.title = '오류'; diff --git a/src/Web/wwwroot/panes/pid.html b/src/Web/wwwroot/panes/pid.html index 9f1a265..e9eb3c5 100644 --- a/src/Web/wwwroot/panes/pid.html +++ b/src/Web/wwwroot/panes/pid.html @@ -84,22 +84,41 @@
- +
+ + + + + + + + + + + + + + + + - + - - - - - - + + + + + + + + +
IDID 태그번호 장비명 유형 카테고리RoleFromTo매핑저장삭제ROLEFROMFROM_atTOTO_atSUB_AREA매핑저장삭제