diff --git a/src/Core/Application/DTOs/PidEquipmentDto.cs b/src/Core/Application/DTOs/PidEquipmentDto.cs index a08a361..614fb8f 100644 --- a/src/Core/Application/DTOs/PidEquipmentDto.cs +++ b/src/Core/Application/DTOs/PidEquipmentDto.cs @@ -17,3 +17,25 @@ public record PidEquipmentDto( string? Role, string? FromTag, string? ToTag); + +public record CreateEquipmentRequest( + string TagNo, + string? EquipmentName = null, + string? InstrumentType = null, + string? Category = null, + bool? TagDcs = null, + string? Role = null, + string? FromTag = null, + string? ToTag = null, + string? TagClass = null); + +public record UpdateEquipmentRequest( + string? TagNo = null, + string? EquipmentName = null, + string? InstrumentType = null, + string? Category = null, + bool? TagDcs = null, + string? Role = null, + string? FromTag = null, + string? ToTag = null, + string? TagClass = null); diff --git a/src/Core/Application/Interfaces/IExperionServices.cs b/src/Core/Application/Interfaces/IExperionServices.cs index 3cc4646..bcddb35 100644 --- a/src/Core/Application/Interfaces/IExperionServices.cs +++ b/src/Core/Application/Interfaces/IExperionServices.cs @@ -318,7 +318,8 @@ public interface IPidExtractorService // 조회 (페이지네이션) Task<(int Total, IEnumerable Items)> GetEquipmentAsync( - string? tagNo, int page, int pageSize); + string? tagNo, string? category, int page, int pageSize, + string? sortBy = null, bool sortDesc = false); Task GetByIdAsync(long id); @@ -326,6 +327,9 @@ public interface IPidExtractorService Task UpdateConfidenceAsync(long id, double confidence); Task ActivateAsync(long id); Task DeactivateAsync(long id); + Task CreateEquipmentAsync(CreateEquipmentRequest request); + Task UpdateEquipmentAsync(long id, UpdateEquipmentRequest request); + Task<(bool Found, string? TagName)> AutoMapTagAsync(long id); Task DeleteAsync(long id); // 통계 diff --git a/src/Core/Application/Services/PidExtractorService.cs b/src/Core/Application/Services/PidExtractorService.cs index 055b0f7..1f98a1b 100644 --- a/src/Core/Application/Services/PidExtractorService.cs +++ b/src/Core/Application/Services/PidExtractorService.cs @@ -378,24 +378,60 @@ public class PidExtractorService : IPidExtractorService private async Task FindFallbackTagAsync(string tagNo) { - var normalized = tagNo.Split('.')[0]; + var normalized = tagNo.Split('.')[0].ToLowerInvariant(); return await _dbContext.RealtimePoints - .FirstOrDefaultAsync(t => t.TagName == normalized - || t.TagName.StartsWith(normalized + ".")); + .FirstOrDefaultAsync(t => t.TagName.ToLower() == normalized + || t.TagName.ToLower().StartsWith(normalized + ".")); } public async Task<(int Total, IEnumerable Items)> GetEquipmentAsync( - string? tagNo, int page, int pageSize) + string? tagNo, string? category, int page, int pageSize, + string? sortBy = null, bool sortDesc = false) { var q = _dbContext.PidEquipment.AsQueryable(); if (!string.IsNullOrEmpty(tagNo)) q = q.Where(e => e.TagNo.Contains(tagNo)); + if (!string.IsNullOrEmpty(category)) + { + if (category == "__unmatched__") + q = q.Where(e => e.Category == null); + else if (category == "instrument_dcs") + q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagDcs); + else if (category == "instrument_field") + q = q.Where(e => e.Category == PidEquipment.CategoryInstrument && !e.TagDcs); + else + q = q.Where(e => e.Category == category); + } + var total = await q.CountAsync(); - var items = await q.OrderByDescending(e => e.ExtractedAt) - .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + + List items; + if (sortBy == "tagName") + { + // DB에서 기본 정렬로 가져온 뒤 C#에서 natural sort + var all = await q.OrderBy(e => e.TagNo.Length).ThenBy(e => e.TagNo).ToListAsync(); + if (sortDesc) + all = all.OrderByDescending(e => TagSortKey(e.TagNo)).ThenByDescending(e => e.TagNo.ToLowerInvariant()).ToList(); + else + all = all.OrderBy(e => TagSortKey(e.TagNo)).ThenBy(e => e.TagNo.ToLowerInvariant()).ToList(); + items = all.Skip((page - 1) * pageSize).Take(pageSize).ToList(); + } + else + { + items = await q.OrderByDescending(e => e.ExtractedAt) + .Skip((page - 1) * pageSize).Take(pageSize).ToListAsync(); + } 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()}"; + } + public async Task GetByIdAsync(long id) => await _dbContext.PidEquipment.Include(e => e.ExperionTag).FirstOrDefaultAsync(e => e.Id == id); @@ -407,6 +443,54 @@ public class PidExtractorService : IPidExtractorService await _dbContext.SaveChangesAsync(); } + public async Task CreateEquipmentAsync(CreateEquipmentRequest request) + { + var e = new PidEquipment + { + TagNo = request.TagNo.ToLowerInvariant(), + EquipmentName = request.EquipmentName, + InstrumentType = request.InstrumentType, + Category = request.Category, + TagDcs = request.TagDcs ?? false, + Role = request.Role, + FromTag = request.FromTag, + ToTag = request.ToTag, + TagClass = request.TagClass ?? ClassifyTagClass(request.Category, request.TagDcs ?? false), + IsActive = true, + Confidence = 1.0, + ExtractedAt = DateTime.UtcNow, + UpdatedAt = DateTime.UtcNow + }; + if (request.FromTag != null || request.ToTag != null) + e.ConnectionLocked = true; + _dbContext.PidEquipment.Add(e); + await _dbContext.SaveChangesAsync(); + _logger.LogInformation("[PID] Created equipment id={Id} tagNo={Tag}", e.Id, e.TagNo); + return e; + } + + public async Task UpdateEquipmentAsync(long id, UpdateEquipmentRequest request) + { + var e = await _dbContext.PidEquipment.FindAsync(id); + if (e == null) return false; + if (request.TagNo != null) e.TagNo = request.TagNo.ToLowerInvariant(); + if (request.EquipmentName != null) e.EquipmentName = request.EquipmentName; + if (request.InstrumentType != null) e.InstrumentType = request.InstrumentType; + if (request.Category != null) e.Category = request.Category; + if (request.TagDcs.HasValue) e.TagDcs = request.TagDcs.Value; + 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.TagClass != null) e.TagClass = request.TagClass; + if (request.Category != null || request.TagDcs.HasValue) + e.TagClass = ClassifyTagClass(e.Category, e.TagDcs); + if (request.FromTag != null || request.ToTag != null) + e.ConnectionLocked = true; + e.UpdatedAt = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + return true; + } + public async Task ActivateAsync(long id) { var e = await _dbContext.PidEquipment.FindAsync(id); @@ -432,6 +516,18 @@ public class PidExtractorService : IPidExtractorService return true; } + public async Task<(bool Found, string? TagName)> AutoMapTagAsync(long id) + { + var e = await _dbContext.PidEquipment.FindAsync(id); + if (e == null) return (false, null); + var match = await FindFallbackTagAsync(e.TagNo ?? ""); + if (match == null) return (false, null); + e.ExperionTagId = match.Id; + e.UpdatedAt = DateTime.UtcNow; + await _dbContext.SaveChangesAsync(); + return (true, match.TagName); + } + public async Task DeleteAllEquipmentAsync() { var count = await _dbContext.PidEquipment.CountAsync(); @@ -485,12 +581,19 @@ public class PidExtractorService : IPidExtractorService var rules = await GetRulesCachedAsync(); var grouped = items - .GroupBy(i => string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!) + .GroupBy(i => + { + var cat = string.IsNullOrEmpty(i.Category) ? "__unmatched__" : i.Category!; + if (cat == PidEquipment.CategoryInstrument) + return i.TagDcs ? "instrument_dcs" : "instrument_field"; + return cat; + }) .ToDictionary(g => g.Key, g => g.ToList()); var sheetOrder = new[] { - PidEquipment.CategoryInstrument, + "instrument_dcs", + "instrument_field", PidEquipment.CategoryPowerEquipment, PidEquipment.CategoryStorageEquipment, PidEquipment.CategoryProcessEquipment, @@ -501,7 +604,8 @@ public class PidExtractorService : IPidExtractorService var sheetNames = new Dictionary { - [PidEquipment.CategoryInstrument] = "Instrument", + ["instrument_dcs"] = "DCS 태그", + ["instrument_field"] = "현장 계기", [PidEquipment.CategoryPowerEquipment] = "Power Equipment", [PidEquipment.CategoryStorageEquipment] = "Storage Equipment", [PidEquipment.CategoryProcessEquipment] = "Process Equipment", @@ -891,10 +995,12 @@ public class PidExtractorService : IPidExtractorService { const int batchSize = 1000; int total = 0; + long lastId = 0; while (true) { var batch = await _dbContext.PidEquipment - .Where(e => e.Category == null) + .Where(e => e.Id > lastId) + .OrderBy(e => e.Id) .Take(batchSize) .ToListAsync(); if (!batch.Any()) break; @@ -902,36 +1008,17 @@ public class PidExtractorService : IPidExtractorService foreach (var item in batch) { var category = await MatchCategoryAsync(item.TagNo); - if (category != null) + var tagDcs = await ResolveTagDcsAsync(item.TagNo); + var tagClass = ClassifyTagClass(category, tagDcs); + if (item.Category != category || item.TagDcs != tagDcs || item.TagClass != tagClass) { - var tagDcs = await ResolveTagDcsAsync(item.TagNo); item.Category = category; item.TagDcs = tagDcs; - item.TagClass = ClassifyTagClass(category, tagDcs); + item.TagClass = tagClass; item.UpdatedAt = DateTime.UtcNow; total++; } - } - await _dbContext.SaveChangesAsync(); - } - - // 이미 instrument 로 분류됐으나 tag_class 미지정인 기존 행 backfill - // (Unmatched/category 자체는 건드리지 않음) - while (true) - { - var batch = await _dbContext.PidEquipment - .Where(e => e.Category == PidEquipment.CategoryInstrument && e.TagClass == null) - .Take(batchSize) - .ToListAsync(); - if (!batch.Any()) break; - - foreach (var item in batch) - { - var tagDcs = await ResolveTagDcsAsync(item.TagNo); - item.TagDcs = tagDcs; - item.TagClass = ClassifyTagClass(item.Category, tagDcs); - item.UpdatedAt = DateTime.UtcNow; - total++; + lastId = item.Id; } await _dbContext.SaveChangesAsync(); } diff --git a/src/Infrastructure/Database/ExperionDbContext.cs b/src/Infrastructure/Database/ExperionDbContext.cs index 47302a1..31a5ea8 100644 --- a/src/Infrastructure/Database/ExperionDbContext.cs +++ b/src/Infrastructure/Database/ExperionDbContext.cs @@ -131,7 +131,7 @@ public class ExperionDbContext : DbContext .HasDefaultValueSql("NOW()"); entity.Property(e => e.UpdatedAt) - .ValueGeneratedOnAddOrUpdate() + .ValueGeneratedOnAdd() .HasDefaultValueSql("NOW()"); entity.HasIndex(e => e.TagNo); @@ -605,19 +605,27 @@ public class ExperionDbService : IExperionDbService WHERE prefix IN ('FIC','TIC','PIC','LIC','FY','TY','PY','LY','FV','TV','PV','LV'); """); - // Step 3: pid_equipment 컬럼 추가 + // Step 3+4: pid_equipment 컬럼 추가 + backfill (최초 1회만 실행) + // ADD COLUMN IF NOT EXISTS는 멱등하지만 뒤따르는 UPDATE backfill이 + // 매번 재실행되면 수동 변경(FIT-9128 tag_dcs=false)을 덮어쓰는 문제가 있음. + // → PL/pgSQL 블록으로 컬럼 존재 여부를 검사하여 최초 1회만 실행 await _ctx.Database.ExecuteSqlRawAsync(""" - ALTER TABLE pid_equipment - ADD COLUMN IF NOT EXISTS tag_dcs BOOLEAN NOT NULL DEFAULT FALSE; - """); + DO $$ + BEGIN + IF NOT EXISTS ( + SELECT 1 FROM information_schema.columns + WHERE table_name='pid_equipment' AND column_name='tag_dcs' + ) THEN + ALTER TABLE pid_equipment + ADD COLUMN tag_dcs BOOLEAN NOT NULL DEFAULT FALSE; - // Step 4: 기존 행 backfill — StartsWith 매칭 (FICQ/FICA 등 compound형 자동 포함) - await _ctx.Database.ExecuteSqlRawAsync(""" - UPDATE pid_equipment pe - SET tag_dcs = TRUE - FROM pid_prefix_rules pr - WHERE pe.instrument_type LIKE (pr.prefix || '%') - AND pr.tag_dcs = TRUE; + UPDATE pid_equipment pe + SET tag_dcs = TRUE + FROM pid_prefix_rules pr + WHERE pe.instrument_type LIKE (pr.prefix || '%') + AND pr.tag_dcs = TRUE; + END IF; + END $$; """); // ───────────────────────────────────────────────────────────────────────── diff --git a/src/Web/Controllers/PidController.cs b/src/Web/Controllers/PidController.cs index b9e12f1..f502d59 100644 --- a/src/Web/Controllers/PidController.cs +++ b/src/Web/Controllers/PidController.cs @@ -107,9 +107,9 @@ public class PidController : ControllerBase } [HttpGet("equipment")] - public async Task GetEquipment(string? tagNo = null, int page = 1, int pageSize = 50) + public async Task GetEquipment(string? tagNo = null, string? category = null, int page = 1, int pageSize = 50, string? sortBy = null, bool sortDesc = false) { - var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, page, pageSize); + var (total, items) = await _pidExtractor.GetEquipmentAsync(tagNo, category, page, pageSize, sortBy, sortDesc); var itemList = items.ToList(); var equipmentDtos = itemList.Select(e => new @@ -127,6 +127,8 @@ public class PidController : ControllerBase experionTagId = e.ExperionTagId, experionTagName = e.ExperionTag?.TagName, category = e.Category, + tagClass = e.TagClass, + tagDcs = e.TagDcs, role = e.Role, fromTag = e.FromTag, toTag = e.ToTag, @@ -144,7 +146,42 @@ public class PidController : ControllerBase items = equipmentDtos }); } - + + [HttpPost("{id}/auto-map")] + public async Task AutoMap(long id) + { + var (found, tagName) = await _pidExtractor.AutoMapTagAsync(id); + if (!found) return NotFound(new { error = "매칭되는 Experion 태그가 없습니다." }); + return Ok(new { tagName }); + } + + [HttpPut("{id}")] + public async Task UpdateEquipment(long id, [FromBody] UpdateEquipmentRequest request) + { + var ok = await _pidExtractor.UpdateEquipmentAsync(id, request); + if (!ok) return NotFound(new { error = "레코드를 찾을 수 없습니다." }); + return NoContent(); + } + + [HttpPost] + public async Task CreateEquipment([FromBody] CreateEquipmentRequest request) + { + var e = await _pidExtractor.CreateEquipmentAsync(request); + return CreatedAtAction(nameof(CreateEquipment), new { id = e.Id }, new + { + id = e.Id, + tagName = e.TagNo, + equipmentName = e.EquipmentName, + instrumentType = e.InstrumentType, + category = e.Category, + tagDcs = e.TagDcs, + tagClass = e.TagClass, + role = e.Role, + fromTag = e.FromTag, + toTag = e.ToTag + }); + } + [HttpGet("statistics")] public async Task GetStatistics() { diff --git a/src/Web/Program.cs b/src/Web/Program.cs index fad7ec4..f5ff576 100644 --- a/src/Web/Program.cs +++ b/src/Web/Program.cs @@ -20,6 +20,8 @@ var mvcBuilder = builder.Services.AddControllers() { // JSON 직렬화 시 대소문자 구분 없이 처리하도록 PascalCase 유지 opt.JsonSerializerOptions.PropertyNamingPolicy = null; + // Deserialize 시 camelCase 키를 C# PascalCase 속성에 매핑 (프론트엔드 호환) + opt.JsonSerializerOptions.PropertyNameCaseInsensitive = true; }); builder.Services.AddMemoryCache(); diff --git a/src/Web/appsettings.json b/src/Web/appsettings.json index a57c12f..63d3f6c 100644 --- a/src/Web/appsettings.json +++ b/src/Web/appsettings.json @@ -4,7 +4,7 @@ "Default": "Information", "Microsoft.AspNetCore": "Warning", "Microsoft.EntityFrameworkCore": "Warning", - "Microsoft.EntityFrameworkCore.Database.Command": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information", "ExperionCrawler.Infrastructure.OpcUa.ExperionHistoryService": "Warning" }, "Console": { diff --git a/src/Web/wwwroot/css/pid.css b/src/Web/wwwroot/css/pid.css index c923636..df5e219 100644 --- a/src/Web/wwwroot/css/pid.css +++ b/src/Web/wwwroot/css/pid.css @@ -88,10 +88,6 @@ font-size: 13px; } -#pane-pid .pid-cat-count { - font-size: 11px; - color: var(--t2); -} #pane-pid .pid-cat-add { display: flex; diff --git a/src/Web/wwwroot/js/pid.js b/src/Web/wwwroot/js/pid.js index 690716b..0d9d90b 100644 --- a/src/Web/wwwroot/js/pid.js +++ b/src/Web/wwwroot/js/pid.js @@ -4,6 +4,9 @@ let pidCurrentPage = 1; let pidPageSize = 20; let pidLastResult = null; // Excel export용 +let pidCatFilter = ''; // 카테고리 필터 +let pidSortBy = ''; // 정렬 필드 +let pidSortDesc = false; // 내림차순 async function pidUpload() { const fileInput = document.getElementById('pid-file-input'); @@ -149,6 +152,23 @@ async function pidExtract() { } } +function pidOnCatFilter() { + pidCatFilter = document.getElementById('pid-cat-filter').value; + pidCurrentPage = 1; + pidLoadTable(1); +} + +function pidToggleSort(field) { + if (pidSortBy === field) { + pidSortDesc = !pidSortDesc; + } else { + pidSortBy = field; + pidSortDesc = false; + } + pidCurrentPage = 1; + pidLoadTable(1); +} + async function pidLoadTable(page = 1) { pidCurrentPage = page; const container = document.getElementById('pid-table-container'); @@ -156,54 +176,71 @@ async function pidLoadTable(page = 1) { if (!container || !tbody) return; - // tbody만 비우고 로딩 상태 표시 — container.innerHTML을 덮어쓰면 tbody가 DOM에서 떨어져 나가 - // 이후 tbody.innerHTML 할당이 화면에 반영되지 않음. - tbody.innerHTML = '로딩 중...'; + const catParam = pidCatFilter ? `&category=${encodeURIComponent(pidCatFilter)}` : ''; + const sortParam = pidSortBy ? `&sortBy=${pidSortBy}&sortDesc=${pidSortDesc}` : ''; + const si = document.getElementById('pid-sort-indicator'); + if (si) si.textContent = pidSortBy === 'tagName' ? (pidSortDesc ? '▼' : '▲') : ''; + tbody.innerHTML = '로딩 중...'; try { - const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}`); + const res = await fetch(`/api/pid/equipment?page=${page}&pageSize=${pidPageSize}${catParam}${sortParam}`); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); pidLastResult = data; if (!data.items || data.items.length === 0) { - tbody.innerHTML = '데이터가 없습니다'; + tbody.innerHTML = '데이터가 없습니다'; document.getElementById('pid-pagination').innerHTML = ''; return; } - tbody.innerHTML = data.items.map(item => ` - + const catOpts = ['','instrument','power_equipment','storage_equipment','process_equipment','utility_equipment','pipings'] + .map(v => ``).join(''); + + // 현재 카테고리 → 가상 키 변환 (instrument + tagDcs 조합) + function pidVcat(item) { + if (item.category === 'instrument') return item.tagDcs ? 'instrument_dcs' : 'instrument_field'; + return item.category || ''; + } + + tbody.innerHTML = data.items.map(item => { + const vcat = pidVcat(item); + const catOpts = CATEGORY_ORDER.map(k => + `` + ).join(''); + return ` ${item.id} - ${esc(item.tagName)} - ${esc(item.equipmentName) || '-'} - ${esc(item.instrumentType) || '-'} - ${esc(item.lineNumber) || '-'} - ${esc(item.pidDrawingNo) || '-'} - ${(item.confidence * 100).toFixed(1)}% - - ${item.isActive ? '활성' : '비활성'} + + + + + + + + ${item.experionTagId - ? `✅ ${esc(item.experionTagName || '')}` - : `` + ? `✅ ${esc((item.experionTagName || '').toUpperCase())}` + : `` } - ${item.category - ? `${esc(item.category)}` - : '-'} + + - + - - `).join(''); + `; + }).join(''); pidRenderPagination(data.total, page); } catch (e) { - tbody.innerHTML = `오류: ${e.message}`; + tbody.innerHTML = `오류: ${e.message}`; } } @@ -218,8 +255,8 @@ function pidRenderPagination(total, currentPage) { } let html = ''; - const start = Math.max(1, currentPage - 3); - const end = Math.min(totalPages, currentPage + 3); + const start = Math.max(1, currentPage - 10); + const end = Math.min(totalPages, currentPage + 10); if (currentPage > 1) { html += ``; @@ -237,6 +274,89 @@ function pidRenderPagination(total, currentPage) { pagination.innerHTML = html; } +function pidShowAddRow() { + const tbody = document.getElementById('pid-table-body'); + if (!tbody) return; + // 이미 추가 행이 있으면 제거 + const existing = tbody.querySelector('tr.pid-add-row'); + if (existing) { existing.remove(); return; } + + const row = document.createElement('tr'); + row.className = 'pid-add-row'; + row.innerHTML = ` + NEW + + + + + + + + + + + + + `; + tbody.insertBefore(row, tbody.firstChild); + row.querySelector('.pid-add-input[data-field="tagNo"]').focus(); +} + +async function pidCreateRow(btn) { + const row = btn.closest('tr'); + const inputs = row.querySelectorAll('.pid-add-input'); + const vcatEl = row.querySelector('.pid-add-vcat'); + const vcat = vcatEl ? vcatEl.value : ''; + const { category, tagDcs } = pidResolveCat(vcat); + const body = { tagNo: '', category: category || null, tagDcs }; + for (const inp of inputs) { + body[inp.dataset.field] = inp.value || null; + } + if (!body.tagNo) { alert('태그번호를 입력하세요.'); return; } + try { + const res = await fetch('/api/pid', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || res.statusText); + } + row.remove(); + await pidLoadTable(pidCurrentPage); + pidUpdateStats(); + } catch (e) { + alert(`추가 실패: ${e.message}`); + } +} + +async function pidSaveRow(id) { + const inputs = document.querySelectorAll(`.pid-edit[data-id="${id}"]`); + const vcatEl = document.querySelector(`.pid-edit-vcat[data-id="${id}"]`); + const vcat = vcatEl ? vcatEl.value : ''; + const { category, tagDcs } = pidResolveCat(vcat); + const body = { category: category || null, tagDcs }; + for (const inp of inputs) { + body[inp.dataset.field] = inp.value || null; + } + try { + const res = await fetch(`/api/pid/${id}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body) + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch (e) { + alert(`저장 실패: ${e.message}`); + } +} + async function pidDeleteRow(id) { if (!confirm('정말 이 레코드를 삭제하시겠습니까?')) return; @@ -408,13 +528,9 @@ async function pidRefreshPrefixRules() { const meta = CATEGORY_META[vcat] || { label: vcat, badge: '', dbCat: vcat, tagDcs: false }; rules?.sort((a, b) => a.sortOrder - b.sortOrder); - // 그룹 헤더: 규칙이 0건이어도 추가 입력행은 항상 표시 - const count = rules ? rules.length : 0; - html += `
${meta.label} - ${count} @@ -426,10 +542,9 @@ async function pidRefreshPrefixRules() { if (rules) { for (const r of rules) { - html += `
+ html += `
- @@ -490,7 +605,7 @@ async function pidUpdatePrefixRule(id, btn) { const { category } = pidResolveCat(vcat); const prefix = row.querySelector('.pid-cat-prefix-input').value.trim(); const desc = row.querySelector('.pid-cat-desc-input').value.trim(); - const order = parseInt(row.querySelector('.pid-cat-order-input').value) || 10; + const order = parseInt(row.dataset.sortOrder) || 10; // data-sort-order 속성에서 읽기 // tagDcs는 그룹(vcat)이 결정 — 행에 별도 체크박스 없음 // 그룹 이동이 필요하면 삭제 후 반대 그룹에서 추가 const tagDcs = CATEGORY_META[vcat]?.tagDcs ?? false; @@ -525,7 +640,7 @@ async function pidDeletePrefixRule(id, prefix) { } async function pidApplyCategories() { - if (!confirm('기존 미분류 항목에 category를 재적용하시겠습니까?')) return; + if (!confirm('전체 레코드에 현재 PREFIX 분류 정의를 재적용하시겠습니까?\n수동으로 변경한 카테고리도 덮어쓰여집니다.')) return; try { const res = await fetch('/api/pid/apply-categories', { method: 'POST' }); if (!res.ok) throw new Error(`HTTP ${res.status}`); @@ -538,9 +653,22 @@ async function pidApplyCategories() { } } -function pidOpenMapping(id) { - // 매핑 모달 열기 (추후 구현) - console.log('pidOpenMapping:', id); +async function pidOpenMapping(id, btn) { + btn.disabled = true; + btn.textContent = '⏳'; + try { + const res = await fetch(`/api/pid/${id}/auto-map`, { method: 'POST' }); + if (!res.ok) { + btn.textContent = '❌'; + btn.title = '매칭 실패'; + return; + } + const data = await res.json(); + btn.outerHTML = `✅ ${esc((data.tagName||'').toUpperCase())}`; + } catch { + btn.textContent = '❌'; + btn.title = '오류'; + } } // 탭 진입 시 초기화 diff --git a/src/Web/wwwroot/panes/pid.html b/src/Web/wwwroot/panes/pid.html index 6873454..9f1a265 100644 --- a/src/Web/wwwroot/panes/pid.html +++ b/src/Web/wwwroot/panes/pid.html @@ -54,7 +54,7 @@
- +
@@ -64,25 +64,42 @@
추출 결과
+
+
+ + +
- + - - - - - - - + + + + + + +
ID태그번호 + 태그번호 + 장비명 유형라인번호도면번호신뢰도상태매핑카테고리삭제카테고리RoleFromTo매핑저장삭제