태그 피커 재설계 — 찾기(초성·건수)와 둘러보기(분류)를 함께

칩으로 자주 쓰는 것을 앞에 깔았지만, 피커 자체는 여전히 384줄짜리 평면 목록이었다.
목록에 구조가 없으면 무엇이 있는지 알 길이 없어 결국 아는 이름만 반복해 쓰게 된다.

■ 분류 — '무엇이 있는지'

태그 이름에는 이미 구조가 있다(PAT_ 환자 · OCM_ 진료·오더 · ETC_ 기관·문서).
그 구조를 왼쪽 분류 목록으로 드러냈다. 건수와 함께 보이므로 규모가 잡힌다:
환자 40 · 진료·오더 260 · 기관·문서 75 · 그 밖 9.

가상 분류 셋을 위에 뒀다:
  · 자주 쓰는 것 — 이 컨트롤 타입의 실사용 상위(타입마다 다르다)
  · 운영에서 쓰임 157 — 384종 중 실제로 한 번이라도 쓰인 것만
  · 전체 384

아는 접두어만 분류로 올리고 나머지는 '그 밖'으로 묶는다. 레거시에는 규약에서 벗어난 이름이
섞여 있어서(오타 ECT_로그인사용자싸인 — bzDataInterface.vb:17850 의 실제 오타다,
접두어가 아예 없는 GetNurseShtCod) 접두어를 그대로 분류로 쓰면 1~3개짜리 분류가
목록을 채워 '무엇이 있는지'를 오히려 가린다.

■ 사용 건수 — '이게 표준인가'

이름만으로는 PAT_주민번호 와 PAT_주민번호_Dash 중 무엇이 표준인지 알 수 없다.
운영 실측(--db-tags)을 줄마다 배지로 띄운다(PAT_이름 669, PAT_차트번호 463 …).
한 번도 안 쓰인 태그는 '미사용'으로 표시한다 — 그것도 정보다.
정렬은 자주 쓰는 순 → 운영 건수 → 이름. 가나다순은 운영 절반을 차지하는 태그와
한 번도 안 쓰인 태그를 같은 무게로 보여 준다.

■ 초성 검색 — 손이 덜 가게

한글 태그 이름이 길어 전체를 칠 이유가 없다. "ㅊㅌㅂㅎ" 네 글자로 PAT_차트번호 에 닿는다.
입력이 초성만으로 이뤄졌을 때만 초성 대조를 시도한다 — 일반 검색어를 초성으로 오인하면 안 된다.
공백으로 나눈 여러 토큰은 모두 만족해야 하고(AND), 일반어와 초성을 섞어도 된다("OCM ㅈㄷㅅ").

■ 그 밖

· 검색칸에서 ↑↓·PageUp/Down 으로 바로 목록을 움직인다 — 검색하고 Tab 으로 옮겨 가는 왕복을 없앴다.
· 현재 값이 있으면 그 값이 속한 분류로 열고 그 줄을 선택해 둔다.
· 검색으로 지금 분류가 비면 결과가 있는 분류로 옮겨 간다(빈 화면을 보여 주지 않는다).
· 창을 720×620 으로 키우고 크기 조절을 열었다. 선택된 값을 하단에 항상 띄운다.
· AutomationProperties.Name 을 붙였다(사내 UI 가이드 접근성 항목).

사내 UI 가이드의 판단 기준을 따랐다 — '빈도 높은 동작에 모달을 쓰지 않는다'.
자주 쓰는 태그는 인스펙터 행의 칩으로 이미 빠져 있어 이 창을 열 필요가 없고,
이 창은 '둘러보기' 전용으로 남긴다(그 용도에는 집중 화면이 맞다).

단위 테스트 20건 추가(분류 매핑·규약 밖 이름 접기·초성 추출·초성 판정·부분 일치·
다중 토큰 AND·변형 묶기·건수 스냅샷).
회귀: 테스트 162/162, 편집 스모크 210건 실패 0,
DB 왕복 1,271건 diff 0/예외 0, 종이 렌더 P062 바이트 동일.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Msystech
2026-08-13 14:02:25 +09:00
co-authored by Claude Opus 5
parent de01012b7e
commit 146d808aac
7 changed files with 778 additions and 39 deletions
@@ -1,24 +1,88 @@
<Window x:Class="SheetMe.Designer.Views.TagPickerDialogView"
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
Title="태그 선택" Width="480" Height="560"
Title="태그 선택" Width="720" Height="620" MinWidth="560" MinHeight="440"
WindowStartupLocation="CenterOwner" ShowInTaskbar="False"
Style="{StaticResource ThemedWindow}">
<DockPanel Margin="12">
<Window.Resources>
<!-- 분류 목록 한 줄: 이름 + 건수 -->
<DataTemplate x:Key="CategoryItem">
<DockPanel MinWidth="120">
<TextBlock DockPanel.Dock="Right" Text="{Binding Count}" FontSize="10.5"
Foreground="{DynamicResource B.Muted}" VerticalAlignment="Center" Margin="8,0,0,0"/>
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis"/>
</DockPanel>
</DataTemplate>
<!-- 태그 한 줄: 이름 + 소분류 + 사용 건수 배지 -->
<DataTemplate x:Key="TagItem">
<DockPanel MinWidth="240" Margin="0,1">
<Border DockPanel.Dock="Right" VerticalAlignment="Center" Margin="8,0,0,0"
CornerRadius="4" Padding="5,1" Background="{DynamicResource B.Input}"
BorderBrush="{DynamicResource B.InputBorder}" BorderThickness="1"
Visibility="{Binding HasUsage, Converter={StaticResource BoolToVisibility}}"
ToolTip="운영 서식에서 쓰인 횟수">
<TextBlock Text="{Binding UsageText}" FontSize="10" Foreground="{DynamicResource B.Muted}"/>
</Border>
<TextBlock DockPanel.Dock="Right" Text="미사용" FontSize="10" Margin="8,0,0,0"
VerticalAlignment="Center" Foreground="{DynamicResource B.Muted}"
Visibility="{Binding IsUnused, Converter={StaticResource BoolToVisibility}}"
ToolTip="운영 서식에서 한 번도 쓰이지 않은 태그입니다"/>
<TextBlock Text="{Binding Name}" TextTrimming="CharacterEllipsis" VerticalAlignment="Center"/>
</DockPanel>
</DataTemplate>
</Window.Resources>
<DockPanel Margin="14">
<TextBlock x:Name="TitleText" DockPanel.Dock="Top" Foreground="{DynamicResource B.Muted}"
TextWrapping="Wrap" Margin="0,0,0,8"/>
TextWrapping="Wrap" Margin="0,0,0,10"/>
<TextBox x:Name="SearchBox" DockPanel.Dock="Top" Padding="4,3"
TextChanged="OnSearchChanged" VerticalContentAlignment="Center"/>
<!-- 검색: 초성·다중 토큰 -->
<DockPanel DockPanel.Dock="Top" Margin="0,0,0,10">
<TextBlock DockPanel.Dock="Right" x:Name="CountText" VerticalAlignment="Center" Margin="10,0,0,0"
Foreground="{DynamicResource B.Muted}" FontSize="11"/>
<TextBox x:Name="SearchBox" Padding="6,4" VerticalContentAlignment="Center"
TextChanged="OnSearchChanged" PreviewKeyDown="OnSearchKeyDown"
AutomationProperties.Name="태그 검색"
ToolTip="이름 일부 또는 초성으로 찾습니다 (예: ㅊㅌㅂㅎ → 차트번호). 공백으로 나누면 모두 포함하는 것만 남습니다."/>
</DockPanel>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,10,0,0">
<TextBlock x:Name="CountText" VerticalAlignment="Center" Margin="0,0,12,0" Foreground="{DynamicResource B.Muted}"/>
<StackPanel DockPanel.Dock="Bottom" Orientation="Horizontal" HorizontalAlignment="Right" Margin="0,12,0,0">
<TextBlock x:Name="SelectedText" VerticalAlignment="Center" Margin="0,0,12,0"
FontFamily="Consolas" FontSize="11.5" Foreground="{DynamicResource B.Muted}"
TextTrimming="CharacterEllipsis" MaxWidth="330"/>
<Button Content="값 지우기" Padding="12,4" Click="OnClear"/>
<Button Content="선택" Padding="18,4" Margin="8,0,0,0" Click="OnPick" IsDefault="True"/>
<Button x:Name="PickButton" Content="선택" Padding="18,4" Margin="8,0,0,0"
Style="{StaticResource Primary}" Click="OnPick" IsDefault="True"/>
<Button Content="취소" Padding="18,4" Margin="8,0,0,0" IsCancel="True"/>
</StackPanel>
<ListBox x:Name="TagList" Margin="0,8,0,0" MouseDoubleClick="OnPick"
VirtualizingPanel.IsVirtualizing="True" FontSize="13"/>
<Grid>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="190"/>
<ColumnDefinition Width="12"/>
<ColumnDefinition Width="*"/>
</Grid.ColumnDefinitions>
<!-- 왼쪽: 분류. '무엇이 있는지' 는 여기서 먼저 보인다 -->
<DockPanel Grid.Column="0">
<TextBlock DockPanel.Dock="Top" Text="분류" FontSize="11"
Foreground="{DynamicResource B.Muted}" Margin="2,0,0,4"/>
<ListBox x:Name="CategoryList" ItemTemplate="{StaticResource CategoryItem}"
SelectionChanged="OnCategoryChanged" FontSize="12.5"
AutomationProperties.Name="태그 분류"/>
</DockPanel>
<!-- 오른쪽: 태그 -->
<DockPanel Grid.Column="2">
<TextBlock DockPanel.Dock="Top" x:Name="ListCaption" FontSize="11"
Foreground="{DynamicResource B.Muted}" Margin="2,0,0,4"/>
<ListBox x:Name="TagList" ItemTemplate="{StaticResource TagItem}"
SelectionChanged="OnTagChanged" MouseDoubleClick="OnPick"
VirtualizingPanel.IsVirtualizing="True" FontSize="12.5"
HorizontalContentAlignment="Stretch"
AutomationProperties.Name="태그 목록"/>
</DockPanel>
</Grid>
</DockPanel>
</Window>
@@ -1,24 +1,73 @@
using System.Windows;
using System.Windows.Controls;
using System.Windows.Input;
using SheetMe.Core.Catalog;
namespace SheetMe.Designer.Views;
/// <summary>분류 목록 한 줄</summary>
public sealed class TagCategoryItem
{
/// <summary>표시 이름</summary>
public string Name { get; init; } = string.Empty;
/// <summary>이 분류에 속한 태그 수(검색 결과 기준)</summary>
public int Count { get; init; }
/// <summary>'전체'·'자주 쓰는 것' 같은 가상 분류인지</summary>
public string? Filter { get; init; }
}
/// <summary>태그 목록 한 줄 — 이름과 사용 실적</summary>
public sealed class TagListItem
{
/// <summary>태그 이름(저장되는 값)</summary>
public string Name { get; init; } = string.Empty;
/// <summary>운영 사용 건수</summary>
public int Usage { get; init; }
/// <summary>배지 문구</summary>
public string UsageText => Usage >= 1000 ? $"{Usage / 1000.0:F1}k" : Usage.ToString();
/// <summary>건수 배지를 띄울지</summary>
public bool HasUsage => Usage > 0;
/// <summary>운영에서 한 번도 안 쓰인 태그인지 — 고르기 전에 알 수 있어야 한다</summary>
public bool IsUnused => Usage == 0;
}
/// <summary>
/// 태그 선택 대화상자 — 검색(부분 일치, 공백 구분 다중 토큰 AND) + 목록 선택.
/// '값 지우기'는 빈 값으로 확정(속성 제거).
/// 태그 선택 — 찾기(검색·초성)와 둘러보기(분류)를 함께.
///
/// <b>왜 분류가 필요한가.</b> 데이터 태그는 384종이다. 한 덩어리로 늘어놓으면
/// 무엇이 있는지 알 길이 없어 결국 아는 이름만 반복해 쓰게 된다.
/// 태그 이름에는 이미 구조가 있다(<c>PAT_</c> 환자 · <c>OCM_</c> 진료 · <c>ETC_</c> 기관) —
/// 그 구조를 화면에 드러낸다.
///
/// <b>왜 건수를 보여 주는가.</b> 이름만으로는 <c>PAT_주민번호</c> 와 <c>PAT_주민번호_Dash</c> 중
/// 무엇이 표준인지 알 수 없다. 운영 실측(--db-tags)을 배지로 띄우면 판단이 선다.
/// 한 번도 안 쓰인 태그는 '미사용'으로 표시한다 — 그것도 정보다.
///
/// 이 창은 <b>둘러보기 전용</b>이다. 자주 쓰는 태그는 인스펙터 행에 칩으로 깔려 있어
/// 이 창을 열 필요가 없다(사내 UI 가이드: 빈도 높은 동작에 모달을 쓰지 않는다).
/// </summary>
public partial class TagPickerDialogView : Window
{
#region Member Fields
private readonly IReadOnlyList<string> allTags;
#endregion
#region Member Fields - Ordering
/// <summary>
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 목록 맨 위로 올린다.
/// 이 컨트롤 타입에서 자주 쓰이는 태그 — 별도 분류로 맨 위에 둔다.
/// 가나다순으로만 늘어놓으면 운영 절반을 차지하는 태그와 한 번도 안 쓰인 태그가 같은 무게로 보인다.
/// </summary>
private readonly IReadOnlyList<string> preferred;
private const string FilterAll = "*";
private const string FilterPreferred = "!";
private const string FilterUsed = "+";
private string activeFilter = FilterAll;
private bool suppressCategoryReload;
#endregion
#region Properties
@@ -34,46 +83,131 @@ public partial class TagPickerDialogView : Window
preferred = preferredTags ?? Array.Empty<string>();
InitializeComponent();
TitleText.Text = title;
ApplyFilter(string.Empty);
if (!string.IsNullOrEmpty(currentValue))
// 현재 값이 있으면 그 값이 든 분류로 열어 준다 — 어디에 속한 값인지가 곧 맥락이다
if (!string.IsNullOrEmpty(currentValue) && allTags.Contains(currentValue))
{
TagList.SelectedItem = tags.FirstOrDefault(t => t == currentValue);
TagList.ScrollIntoView(TagList.SelectedItem);
activeFilter = TagSearch.CategoryOf(currentValue);
}
else if (preferred.Count > 0)
{
activeFilter = FilterPreferred;
}
Rebuild(selectValue: currentValue);
Loaded += (_, _) => SearchBox.Focus();
}
#endregion
#region Methods
private void OnSearchChanged(object sender, TextChangedEventArgs e)
=> ApplyFilter(SearchBox.Text);
private static string[] Tokens(string keyword)
=> keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
private void ApplyFilter(string keyword)
/// <summary>검색어를 통과한 태그 전체(분류 적용 전)</summary>
private List<string> Searched()
{
var tokens = keyword.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
IEnumerable<string> matched = tokens.Length == 0
? allTags
: allTags.Where(t => tokens.All(k => t.Contains(k, StringComparison.OrdinalIgnoreCase)));
var tokens = Tokens(SearchBox.Text);
return tokens.Length == 0
? allTags.ToList()
: allTags.Where(t => TagSearch.MatchesAll(t, tokens)).ToList();
}
// 자주 쓰는 것 먼저, 그 안에서는 빈도 순서 그대로. 나머지는 원래(가나다) 순서를 지킨다.
// OrderBy 는 안정 정렬이라 같은 순위끼리는 입력 순서가 보존된다.
var filtered = matched
private bool PassesFilter(string tag) => activeFilter switch
{
FilterAll => true,
FilterPreferred => preferred.Contains(tag, StringComparer.Ordinal),
FilterUsed => LegacyTagUsageCounts.IsUsed(tag),
var category => string.Equals(TagSearch.CategoryOf(tag), category, StringComparison.Ordinal),
};
/// <summary>분류 목록과 태그 목록을 함께 다시 만든다</summary>
private void Rebuild(string? selectValue = null)
{
var searched = Searched();
// ── 분류 ──
var categories = new List<TagCategoryItem>();
if (preferred.Count > 0)
{
categories.Add(new TagCategoryItem
{
Name = "자주 쓰는 것",
Filter = FilterPreferred,
Count = searched.Count(t => preferred.Contains(t, StringComparer.Ordinal)),
});
}
categories.Add(new TagCategoryItem
{
Name = "운영에서 쓰임",
Filter = FilterUsed,
Count = searched.Count(LegacyTagUsageCounts.IsUsed),
});
categories.Add(new TagCategoryItem { Name = "전체", Filter = FilterAll, Count = searched.Count });
var groups = searched
.GroupBy(TagSearch.CategoryOf)
.OrderByDescending(g => g.Sum(t => LegacyTagUsageCounts.Of(t)))
.ThenBy(g => g.Key, StringComparer.Ordinal)
.ToList();
// 분류가 하나뿐이면 '전체' 와 같은 뜻이라 줄만 늘린다(액션 태그는 접두어 규약이 없어 늘 이 경우다)
if (groups.Count > 1)
{
foreach (var group in groups)
{
categories.Add(new TagCategoryItem { Name = group.Key, Filter = group.Key, Count = group.Count() });
}
}
suppressCategoryReload = true;
CategoryList.ItemsSource = categories;
var active = categories.FirstOrDefault(c => c.Filter == activeFilter);
// 검색으로 현재 분류가 사라졌으면 결과가 있는 곳으로 옮겨 간다 — 빈 화면을 보여 주지 않는다
if (active is null || active.Count == 0)
{
active = categories.FirstOrDefault(c => c.Count > 0) ?? categories[^1];
activeFilter = active.Filter ?? FilterAll;
}
CategoryList.SelectedItem = active;
suppressCategoryReload = false;
RebuildTagList(searched, selectValue);
}
private void RebuildTagList(List<string> searched, string? selectValue = null)
{
var items = searched
.Where(PassesFilter)
// 자주 쓰는 순서 → 운영 건수 → 이름. 가나다순만으로는 표준과 희귀가 구분되지 않는다.
.OrderBy(t =>
{
var index = IndexOfPreferred(t);
return index < 0 ? int.MaxValue : index;
})
.ThenByDescending(LegacyTagUsageCounts.Of)
.ThenBy(t => t, StringComparer.Ordinal)
.Select(t => new TagListItem { Name = t, Usage = LegacyTagUsageCounts.Of(t) })
.ToList();
TagList.ItemsSource = filtered;
var hit = filtered.Count(t => IndexOfPreferred(t) >= 0);
CountText.Text = hit > 0
? $"{filtered.Count}/{allTags.Count}건 · 자주 쓰는 것 {hit}건이 위에"
: $"{filtered.Count}/{allTags.Count}건";
if (filtered.Count > 0)
TagList.ItemsSource = items;
ListCaption.Text = items.Count == 0
? "결과 없음"
: $"{items.Count}개 — 자주 쓰는 것과 많이 쓰인 것이 위에";
CountText.Text = $"{Searched().Count}/{allTags.Count}건";
if (selectValue is { Length: > 0 })
{
var found = items.FirstOrDefault(i => i.Name == selectValue);
if (found is not null)
{
TagList.SelectedItem = found;
TagList.ScrollIntoView(found);
}
}
if (TagList.SelectedItem is null && items.Count > 0)
{
TagList.SelectedIndex = 0;
}
UpdateSelectedText();
}
private int IndexOfPreferred(string tag)
@@ -88,13 +222,65 @@ public partial class TagPickerDialogView : Window
return -1;
}
private void OnPick(object sender, RoutedEventArgs e)
private void UpdateSelectedText()
{
if (TagList.SelectedItem is not string tag)
var tag = (TagList.SelectedItem as TagListItem)?.Name;
SelectedText.Text = tag ?? string.Empty;
PickButton.IsEnabled = tag is { Length: > 0 };
}
private void OnSearchChanged(object sender, TextChangedEventArgs e) => Rebuild();
/// <summary>
/// 검색칸에서 바로 목록을 움직인다 — 검색하고 Tab 으로 옮겨 가는 왕복을 없앤다.
/// Enter 는 선택 확정(IsDefault 버튼이 받는다).
/// </summary>
private void OnSearchKeyDown(object sender, KeyEventArgs e)
{
if (TagList.Items.Count == 0)
{
return;
}
SelectedTag = tag;
switch (e.Key)
{
case Key.Down:
TagList.SelectedIndex = Math.Min(TagList.SelectedIndex + 1, TagList.Items.Count - 1);
break;
case Key.Up:
TagList.SelectedIndex = Math.Max(TagList.SelectedIndex - 1, 0);
break;
case Key.PageDown:
TagList.SelectedIndex = Math.Min(TagList.SelectedIndex + 10, TagList.Items.Count - 1);
break;
case Key.PageUp:
TagList.SelectedIndex = Math.Max(TagList.SelectedIndex - 10, 0);
break;
default:
return;
}
TagList.ScrollIntoView(TagList.SelectedItem);
e.Handled = true;
}
private void OnCategoryChanged(object sender, SelectionChangedEventArgs e)
{
if (suppressCategoryReload || CategoryList.SelectedItem is not TagCategoryItem item)
{
return;
}
activeFilter = item.Filter ?? FilterAll;
RebuildTagList(Searched());
}
private void OnTagChanged(object sender, SelectionChangedEventArgs e) => UpdateSelectedText();
private void OnPick(object sender, RoutedEventArgs e)
{
if (TagList.SelectedItem is not TagListItem item)
{
return;
}
SelectedTag = item.Name;
DialogResult = true;
}