diff --git a/src/AxCopilot/App.xaml.cs b/src/AxCopilot/App.xaml.cs
index 879c4ca..e68db80 100644
--- a/src/AxCopilot/App.xaml.cs
+++ b/src/AxCopilot/App.xaml.cs
@@ -350,6 +350,16 @@ public partial class App : System.Windows.Application
// L22-4: 업무 양식·문서 구조 템플릿 (prefix=form)
commandResolver.RegisterHandler(new FormHandler());
+ // ─── L23: 한국 사무 환경 특화 ─────────────────────────────────────
+ // L23-1: 한국 공휴일·업무일 달력 (prefix=cal)
+ commandResolver.RegisterHandler(new CalHandler());
+ // L23-2: 연차·휴가 관리 (prefix=leave)
+ commandResolver.RegisterHandler(new LeaveHandler());
+ // L23-3: 근무 시간·급여 계산 (prefix=work)
+ commandResolver.RegisterHandler(new WorkTimeHandler());
+ // L23-4: 한/영 타이핑 오류 교정 (prefix=fix)
+ commandResolver.RegisterHandler(new FixHandler());
+
// ─── 플러그인 로드 ────────────────────────────────────────────────────
var pluginHost = new PluginHost(settings, commandResolver);
pluginHost.LoadAll();
diff --git a/src/AxCopilot/Handlers/CalHandler.cs b/src/AxCopilot/Handlers/CalHandler.cs
new file mode 100644
index 0000000..1d7d938
--- /dev/null
+++ b/src/AxCopilot/Handlers/CalHandler.cs
@@ -0,0 +1,349 @@
+using System.Globalization;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L23-1: 한국 공휴일·업무일 달력. "cal" 프리픽스로 사용합니다.
+///
+/// 예: cal → 이번달 달력·공휴일
+/// cal next → 다음 공휴일 D-day (5개)
+/// cal workdays → 이번달 업무일 수·잔여 업무일
+/// cal today → 오늘 공휴일 여부
+/// cal 2026-05 → 특정 월 조회
+/// cal 2026-04-10 → 특정 날짜 조회
+/// Enter → 복사
+///
+public class CalHandler : IActionHandler
+{
+ public string? Prefix => "cal";
+
+ public PluginMetadata Metadata => new(
+ "한국 달력",
+ "공휴일·업무일 달력 — 이번달 · 다음 공휴일 · 업무일 계산",
+ "1.0",
+ "AX");
+
+ // ── 공휴일 데이터 ─────────────────────────────────────────────────────────
+ private static readonly Dictionary Holidays = new()
+ {
+ // 2024
+ { new DateOnly(2024, 1, 1), "신정" },
+ { new DateOnly(2024, 2, 9), "설날연휴" },
+ { new DateOnly(2024, 2, 10), "설날" },
+ { new DateOnly(2024, 2, 11), "설날연휴" },
+ { new DateOnly(2024, 2, 12), "대체공휴일" },
+ { new DateOnly(2024, 3, 1), "삼일절" },
+ { new DateOnly(2024, 4, 10), "국회의원선거" },
+ { new DateOnly(2024, 5, 5), "어린이날" },
+ { new DateOnly(2024, 5, 6), "대체공휴일" },
+ { new DateOnly(2024, 5, 15), "부처님오신날" },
+ { new DateOnly(2024, 6, 6), "현충일" },
+ { new DateOnly(2024, 8, 15), "광복절" },
+ { new DateOnly(2024, 9, 16), "추석연휴" },
+ { new DateOnly(2024, 9, 17), "추석" },
+ { new DateOnly(2024, 9, 18), "추석연휴" },
+ { new DateOnly(2024, 10, 3), "개천절" },
+ { new DateOnly(2024, 10, 9), "한글날" },
+ { new DateOnly(2024, 12, 25), "크리스마스" },
+
+ // 2025
+ { new DateOnly(2025, 1, 1), "신정" },
+ { new DateOnly(2025, 1, 28), "설날연휴" },
+ { new DateOnly(2025, 1, 29), "설날" },
+ { new DateOnly(2025, 1, 30), "설날연휴" },
+ { new DateOnly(2025, 3, 1), "삼일절" },
+ { new DateOnly(2025, 3, 3), "대체공휴일" },
+ { new DateOnly(2025, 5, 5), "어린이날" },
+ { new DateOnly(2025, 5, 6), "부처님오신날" },
+ { new DateOnly(2025, 6, 6), "현충일" },
+ { new DateOnly(2025, 8, 15), "광복절" },
+ { new DateOnly(2025, 10, 3), "개천절" },
+ { new DateOnly(2025, 10, 5), "추석연휴" },
+ { new DateOnly(2025, 10, 6), "추석" },
+ { new DateOnly(2025, 10, 7), "추석연휴" },
+ { new DateOnly(2025, 10, 8), "대체공휴일" },
+ { new DateOnly(2025, 10, 9), "한글날" },
+ { new DateOnly(2025, 12, 25), "크리스마스" },
+
+ // 2026
+ { new DateOnly(2026, 1, 1), "신정" },
+ { new DateOnly(2026, 2, 17), "설날연휴" },
+ { new DateOnly(2026, 2, 18), "설날" },
+ { new DateOnly(2026, 2, 19), "설날연휴" },
+ { new DateOnly(2026, 3, 1), "삼일절" },
+ { new DateOnly(2026, 3, 2), "대체공휴일" },
+ { new DateOnly(2026, 5, 5), "어린이날" },
+ { new DateOnly(2026, 5, 24), "부처님오신날" },
+ { new DateOnly(2026, 5, 25), "대체공휴일" },
+ { new DateOnly(2026, 6, 6), "현충일" },
+ { new DateOnly(2026, 6, 8), "대체공휴일" },
+ { new DateOnly(2026, 8, 15), "광복절" },
+ { new DateOnly(2026, 8, 17), "대체공휴일" },
+ { new DateOnly(2026, 9, 24), "추석연휴" },
+ { new DateOnly(2026, 9, 25), "추석" },
+ { new DateOnly(2026, 9, 26), "추석연휴" },
+ { new DateOnly(2026, 10, 3), "개천절" },
+ { new DateOnly(2026, 10, 5), "대체공휴일" },
+ { new DateOnly(2026, 10, 9), "한글날" },
+ { new DateOnly(2026, 12, 25), "크리스마스" },
+
+ // 2027
+ { new DateOnly(2027, 1, 1), "신정" },
+ { new DateOnly(2027, 2, 7), "설날연휴" },
+ { new DateOnly(2027, 2, 8), "설날" },
+ { new DateOnly(2027, 2, 9), "설날연휴" },
+ { new DateOnly(2027, 3, 1), "삼일절" },
+ { new DateOnly(2027, 5, 5), "어린이날" },
+ { new DateOnly(2027, 5, 13), "부처님오신날" },
+ { new DateOnly(2027, 6, 6), "현충일" },
+ { new DateOnly(2027, 6, 7), "대체공휴일" },
+ { new DateOnly(2027, 8, 15), "광복절" },
+ { new DateOnly(2027, 8, 16), "대체공휴일" },
+ { new DateOnly(2027, 9, 13), "추석연휴" },
+ { new DateOnly(2027, 9, 14), "추석" },
+ { new DateOnly(2027, 9, 15), "추석연휴" },
+ { new DateOnly(2027, 10, 3), "개천절" },
+ { new DateOnly(2027, 10, 4), "대체공휴일" },
+ { new DateOnly(2027, 10, 9), "한글날" },
+ { new DateOnly(2027, 12, 25), "크리스마스" },
+ { new DateOnly(2027, 12, 27), "대체공휴일" },
+ };
+
+ private static readonly string[] DayNames = ["일", "월", "화", "수", "목", "금", "토"];
+
+ // ── 헬퍼 ─────────────────────────────────────────────────────────────────
+
+ private static bool IsHoliday(DateOnly d) =>
+ Holidays.ContainsKey(d) || d.DayOfWeek == DayOfWeek.Saturday || d.DayOfWeek == DayOfWeek.Sunday;
+
+ private static bool IsWorkday(DateOnly d) => !IsHoliday(d);
+
+ private static int CountWorkdays(int year, int month)
+ {
+ var days = DateTime.DaysInMonth(year, month);
+ var count = 0;
+ for (var i = 1; i <= days; i++)
+ if (IsWorkday(new DateOnly(year, month, i))) count++;
+ return count;
+ }
+
+ private static int CountWorkdaysFrom(DateOnly from, DateOnly to)
+ {
+ var count = 0;
+ var cur = from;
+ while (cur <= to)
+ {
+ if (IsWorkday(cur)) count++;
+ cur = cur.AddDays(1);
+ }
+ return count;
+ }
+
+ private static string DayOfWeekKor(DayOfWeek dow) => DayNames[(int)dow];
+
+ // ── GetItemsAsync ─────────────────────────────────────────────────────────
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var today = DateOnly.FromDateTime(DateTime.Today);
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ BuildMonthItems(today.Year, today.Month, today, items);
+ return Task.FromResult>(items);
+ }
+
+ var kw = q.ToLowerInvariant();
+
+ // next → 다음 공휴일 5개
+ if (kw == "next")
+ {
+ items.Add(new LauncherItem("다음 공휴일 5개", "D-N일 표시 · Enter: 클립보드 복사",
+ null, null, Symbol: "\uE787"));
+ var upcoming = Holidays.Keys
+ .Where(d => d > today)
+ .OrderBy(d => d)
+ .Take(5);
+ foreach (var d in upcoming)
+ {
+ var diff = d.DayNumber - today.DayNumber;
+ var name = Holidays[d];
+ var label = $"{d:yyyy-MM-dd} ({DayOfWeekKor(d.DayOfWeek)}) — {name} D-{diff}일";
+ items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
+ null, ("copy", $"{d:yyyy-MM-dd} {name}"), Symbol: "\uE787"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // workdays → 이번달 업무일
+ if (kw == "workdays")
+ {
+ var total = CountWorkdays(today.Year, today.Month);
+ var remaining = CountWorkdaysFrom(today, new DateOnly(today.Year, today.Month,
+ DateTime.DaysInMonth(today.Year, today.Month)));
+ items.Add(new LauncherItem(
+ $"{today.Year}년 {today.Month}월 업무일 — 총 {total}일",
+ $"오늘 기준 잔여 업무일: {remaining}일",
+ null, ("copy", $"{today.Year}년 {today.Month}월 업무일: 총 {total}일, 잔여 {remaining}일"),
+ Symbol: "\uE787"));
+
+ var monthHolidays = Holidays.Where(kv =>
+ kv.Key.Year == today.Year && kv.Key.Month == today.Month)
+ .OrderBy(kv => kv.Key);
+ foreach (var kv in monthHolidays)
+ {
+ var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
+ items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
+ null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
+ }
+ if (!monthHolidays.Any())
+ items.Add(new LauncherItem("이번 달 공휴일 없음", "", null, null, Symbol: "\uE787"));
+ return Task.FromResult>(items);
+ }
+
+ // today → 오늘 정보
+ if (kw == "today")
+ {
+ var isHol = Holidays.TryGetValue(today, out var holName);
+ var isWknd = today.DayOfWeek == DayOfWeek.Saturday || today.DayOfWeek == DayOfWeek.Sunday;
+ string status;
+ if (isHol) status = $"공휴일 ({holName})";
+ else if (isWknd) status = "주말";
+ else status = "평일 (업무일)";
+
+ items.Add(new LauncherItem(
+ $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) — {status}",
+ "Enter: 클립보드 복사",
+ null, ("copy", $"{today:yyyy-MM-dd} ({DayOfWeekKor(today.DayOfWeek)}) {status}"),
+ Symbol: "\uE787"));
+
+ var next = Holidays.Keys.Where(d => d > today).OrderBy(d => d).FirstOrDefault();
+ if (next != default)
+ {
+ var diff = next.DayNumber - today.DayNumber;
+ items.Add(new LauncherItem(
+ $"다음 공휴일: {next:yyyy-MM-dd} ({DayOfWeekKor(next.DayOfWeek)}) — {Holidays[next]} D-{diff}일",
+ "", null, ("copy", $"{next:yyyy-MM-dd} {Holidays[next]}"), Symbol: "\uE787"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // yyyy-MM-dd 날짜 직접 조회
+ var dateFormats = new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMMdd" };
+ if (DateOnly.TryParseExact(q, dateFormats, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var specificDate))
+ {
+ var isHol = Holidays.TryGetValue(specificDate, out var hName);
+ var isWknd = specificDate.DayOfWeek == DayOfWeek.Saturday ||
+ specificDate.DayOfWeek == DayOfWeek.Sunday;
+ string status;
+ if (isHol) status = $"공휴일 ({hName})";
+ else if (isWknd) status = "주말";
+ else status = "평일 (업무일)";
+ items.Add(new LauncherItem(
+ $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) — {status}",
+ "Enter: 클립보드 복사",
+ null, ("copy", $"{specificDate:yyyy-MM-dd} ({DayOfWeekKor(specificDate.DayOfWeek)}) {status}"),
+ Symbol: "\uE787"));
+ return Task.FromResult>(items);
+ }
+
+ // yyyy-MM or yyyy/MM or yyyyMM 월 조회
+ var monthFormats = new[] { "yyyy-MM", "yyyy/MM", "yyyyMM" };
+ if (DateOnly.TryParseExact(q + "-01", new[] { "yyyy-MM-dd", "yyyy/MM/dd", "yyyyMM-dd" },
+ CultureInfo.InvariantCulture, DateTimeStyles.None, out var monthDate) ||
+ TryParseYearMonth(q, out monthDate))
+ {
+ BuildMonthItems(monthDate.Year, monthDate.Month, today, items);
+ return Task.FromResult>(items);
+ }
+
+ // 기본 — 이번달
+ BuildMonthItems(today.Year, today.Month, today, items);
+ return Task.FromResult>(items);
+ }
+
+ private static bool TryParseYearMonth(string q, out DateOnly result)
+ {
+ result = default;
+ // yyyy-MM, yyyy/MM, yyyyMM
+ var formats = new[] { "yyyy-MM", "yyyy/MM" };
+ foreach (var fmt in formats)
+ {
+ if (DateTime.TryParseExact(q, fmt, CultureInfo.InvariantCulture,
+ DateTimeStyles.None, out var dt))
+ {
+ result = new DateOnly(dt.Year, dt.Month, 1);
+ return true;
+ }
+ }
+ // yyyyMM (6자리)
+ if (q.Length == 6 && int.TryParse(q[..4], out var y) && int.TryParse(q[4..], out var m)
+ && m >= 1 && m <= 12)
+ {
+ result = new DateOnly(y, m, 1);
+ return true;
+ }
+ return false;
+ }
+
+ private static void BuildMonthItems(int year, int month, DateOnly today, List items)
+ {
+ var total = CountWorkdays(year, month);
+ var lastDay = new DateOnly(year, month, DateTime.DaysInMonth(year, month));
+ int remaining;
+ if (today.Year == year && today.Month == month)
+ remaining = CountWorkdaysFrom(today, lastDay);
+ else if (today < new DateOnly(year, month, 1))
+ remaining = total;
+ else
+ remaining = 0;
+
+ var header = $"{year}년 {month}월 · 업무일 {total}일";
+ if (today.Year == year && today.Month == month)
+ header += $" (잔여 {remaining}일)";
+
+ items.Add(new LauncherItem(header, "공휴일 목록 · Enter: 클립보드 복사",
+ null, ("copy", header), Symbol: "\uE787"));
+
+ var monthHolidays = Holidays
+ .Where(kv => kv.Key.Year == year && kv.Key.Month == month)
+ .OrderBy(kv => kv.Key)
+ .ToList();
+
+ if (monthHolidays.Count == 0)
+ {
+ items.Add(new LauncherItem("공휴일 없음", "", null, null, Symbol: "\uE787"));
+ }
+ else
+ {
+ foreach (var kv in monthHolidays)
+ {
+ var label = $"🎌 {kv.Key.Day}일 ({DayOfWeekKor(kv.Key.DayOfWeek)}) — {kv.Value}";
+ items.Add(new LauncherItem(label, "Enter: 클립보드 복사",
+ null, ("copy", $"{kv.Key:yyyy-MM-dd} {kv.Value}"), Symbol: "\uE787"));
+ }
+ }
+ }
+
+ // ── ExecuteAsync ──────────────────────────────────────────────────────────
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text))
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("달력", "클립보드에 복사했습니다.");
+ }
+ catch { }
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AxCopilot/Handlers/FixHandler.cs b/src/AxCopilot/Handlers/FixHandler.cs
new file mode 100644
index 0000000..f202fc9
--- /dev/null
+++ b/src/AxCopilot/Handlers/FixHandler.cs
@@ -0,0 +1,539 @@
+using System.Text;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L23-4: 한/영 타이핑 오류 교정. "fix" 프리픽스로 사용합니다.
+///
+/// 예: fix gksrmf → 안녕 (영타→한글 변환)
+/// fix → 클립보드 텍스트 자동 교정
+/// Enter → 교정 결과 클립보드 복사
+///
+public class FixHandler : IActionHandler
+{
+ public string? Prefix => "fix";
+
+ public PluginMetadata Metadata => new(
+ "타이핑 교정",
+ "영타→한글 변환 — 잘못 입력된 영문 타이핑을 한글로 교정",
+ "1.0",
+ "AX");
+
+ // ── 두벌식 영→자모 매핑 ──────────────────────────────────────────────────
+
+ // 소문자
+ private static readonly Dictionary EngToJamo = new()
+ {
+ {'q', 'ㅂ'}, {'w', 'ㅈ'}, {'e', 'ㄷ'}, {'r', 'ㄱ'}, {'t', 'ㅅ'},
+ {'y', 'ㅛ'}, {'u', 'ㅕ'}, {'i', 'ㅑ'}, {'o', 'ㅐ'}, {'p', 'ㅔ'},
+ {'a', 'ㅁ'}, {'s', 'ㄴ'}, {'d', 'ㅇ'}, {'f', 'ㄹ'}, {'g', 'ㅎ'},
+ {'h', 'ㅗ'}, {'j', 'ㅓ'}, {'k', 'ㅏ'}, {'l', 'ㅣ'},
+ {'z', 'ㅋ'}, {'x', 'ㅌ'}, {'c', 'ㅊ'}, {'v', 'ㅍ'},
+ {'b', 'ㅠ'}, {'n', 'ㅜ'}, {'m', 'ㅡ'},
+ // 대문자 = 소문자와 동일 기본 (Shift 된소리/쌍모음 별도)
+ {'Q', 'ㅃ'}, {'W', 'ㅉ'}, {'E', 'ㄸ'}, {'R', 'ㄲ'}, {'T', 'ㅆ'},
+ {'Y', 'ㅛ'}, {'U', 'ㅕ'}, {'I', 'ㅑ'}, {'O', 'ㅒ'}, {'P', 'ㅖ'},
+ {'A', 'ㅁ'}, {'S', 'ㄴ'}, {'D', 'ㅇ'}, {'F', 'ㄹ'}, {'G', 'ㅎ'},
+ {'H', 'ㅗ'}, {'J', 'ㅓ'}, {'K', 'ㅏ'}, {'L', 'ㅣ'},
+ {'Z', 'ㅋ'}, {'X', 'ㅌ'}, {'C', 'ㅊ'}, {'V', 'ㅍ'},
+ {'B', 'ㅠ'}, {'N', 'ㅜ'}, {'M', 'ㅡ'},
+ };
+
+ // ── 초성 배열 (19개) ──────────────────────────────────────────────────────
+ private static readonly char[] Choseong =
+ ['ㄱ','ㄲ','ㄴ','ㄷ','ㄸ','ㄹ','ㅁ','ㅂ','ㅃ','ㅅ','ㅆ','ㅇ','ㅈ','ㅉ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
+
+ // ── 중성 배열 (21개) ──────────────────────────────────────────────────────
+ private static readonly char[] Jungseong =
+ ['ㅏ','ㅐ','ㅑ','ㅒ','ㅓ','ㅔ','ㅕ','ㅖ','ㅗ','ㅘ','ㅙ','ㅚ','ㅛ','ㅜ','ㅝ','ㅞ','ㅟ','ㅠ','ㅡ','ㅢ','ㅣ'];
+
+ // ── 종성 배열 (28개, 0=없음) ─────────────────────────────────────────────
+ private static readonly char[] Jongseong =
+ ['\0','ㄱ','ㄲ','ㄳ','ㄴ','ㄵ','ㄶ','ㄷ','ㄹ','ㄺ','ㄻ','ㄼ','ㄽ','ㄾ','ㄿ','ㅀ','ㅁ','ㅂ','ㅄ','ㅅ','ㅆ','ㅇ','ㅈ','ㅊ','ㅋ','ㅌ','ㅍ','ㅎ'];
+
+ // ── 복합 모음 ─────────────────────────────────────────────────────────────
+ private static readonly Dictionary<(char, char), char> CompoundVowel = new()
+ {
+ {('ㅗ','ㅏ'), 'ㅘ'}, {('ㅗ','ㅐ'), 'ㅙ'}, {('ㅗ','ㅣ'), 'ㅚ'},
+ {('ㅜ','ㅓ'), 'ㅝ'}, {('ㅜ','ㅔ'), 'ㅞ'}, {('ㅜ','ㅣ'), 'ㅟ'},
+ {('ㅡ','ㅣ'), 'ㅢ'},
+ };
+
+ // ── 복합 종성 ─────────────────────────────────────────────────────────────
+ private static readonly Dictionary<(char, char), char> CompoundJong = new()
+ {
+ {('ㄱ','ㅅ'), 'ㄳ'}, {('ㄴ','ㅈ'), 'ㄵ'}, {('ㄴ','ㅎ'), 'ㄶ'},
+ {('ㄹ','ㄱ'), 'ㄺ'}, {('ㄹ','ㅁ'), 'ㄻ'}, {('ㄹ','ㅂ'), 'ㄼ'},
+ {('ㄹ','ㅅ'), 'ㄽ'}, {('ㄹ','ㅌ'), 'ㄾ'}, {('ㄹ','ㅍ'), 'ㄿ'},
+ {('ㄹ','ㅎ'), 'ㅀ'}, {('ㅂ','ㅅ'), 'ㅄ'},
+ };
+
+ // 복합 종성 분리 (모음이 올 때 jong → cho + remain)
+ private static readonly Dictionary SplitJong = new()
+ {
+ {'ㄳ', ('ㄱ','ㅅ')}, {'ㄵ', ('ㄴ','ㅈ')}, {'ㄶ', ('ㄴ','ㅎ')},
+ {'ㄺ', ('ㄹ','ㄱ')}, {'ㄻ', ('ㄹ','ㅁ')}, {'ㄼ', ('ㄹ','ㅂ')},
+ {'ㄽ', ('ㄹ','ㅅ')}, {'ㄾ', ('ㄹ','ㅌ')}, {'ㄿ', ('ㄹ','ㅍ')},
+ {'ㅀ', ('ㄹ','ㅎ')}, {'ㅄ', ('ㅂ','ㅅ')},
+ };
+
+ // ── 인덱스 헬퍼 ──────────────────────────────────────────────────────────
+
+ private static int ChoIdx(char c) => Array.IndexOf(Choseong, c);
+ private static int JungIdx(char c) => Array.IndexOf(Jungseong, c);
+ private static int JongIdx(char c) => Array.IndexOf(Jongseong, c);
+
+ private static bool IsVowel(char jamo) => JungIdx(jamo) >= 0;
+ private static bool IsConsonant(char jamo) => ChoIdx(jamo) >= 0 || JongIdx(jamo) > 0;
+
+ private static char MakeSyllable(int cho, int jung, int jong) =>
+ (char)(0xAC00 + cho * 21 * 28 + jung * 28 + jong);
+
+ // ── 한글 조합기 (두벌식 상태 기계) ───────────────────────────────────────
+
+ private sealed class HangulComposer
+ {
+ private int _cho = -1;
+ private int _jung = -1;
+ private int _jong = -1;
+ private readonly StringBuilder _sb = new();
+
+ public string Result => _sb.ToString();
+
+ public void Flush()
+ {
+ if (_cho < 0) return;
+ if (_jung < 0)
+ {
+ // 초성만
+ _sb.Append(Choseong[_cho]);
+ }
+ else
+ {
+ _sb.Append(MakeSyllable(_cho, _jung, _jong < 0 ? 0 : _jong));
+ }
+ _cho = _jung = _jong = -1;
+ }
+
+ public void Feed(char jamo)
+ {
+ if (IsVowel(jamo))
+ {
+ FeedVowel(jamo);
+ }
+ else
+ {
+ FeedConsonant(jamo);
+ }
+ }
+
+ private void FeedVowel(char v)
+ {
+ var vi = JungIdx(v);
+
+ if (_cho < 0)
+ {
+ // 초성 없음 → ㅇ + 모음
+ _sb.Append(MakeSyllable(ChoIdx('ㅇ'), vi, 0));
+ return;
+ }
+
+ if (_jung < 0)
+ {
+ // 초성만 있음 → 중성 결합
+ _jung = vi;
+ return;
+ }
+
+ // 초성+중성 있음
+ if (_jong < 0)
+ {
+ // 복합 모음 시도
+ var curVowel = Jungseong[_jung];
+ if (CompoundVowel.TryGetValue((curVowel, v), out var compound))
+ {
+ _jung = JungIdx(compound);
+ }
+ else
+ {
+ // 현재 음절 확정, 새 음절 시작 (ㅇ + 모음)
+ Flush();
+ _cho = ChoIdx('ㅇ');
+ _jung = vi;
+ }
+ return;
+ }
+
+ // 초성+중성+종성 있음 → 종성을 새 음절의 초성으로
+ var jongChar = Jongseong[_jong];
+
+ if (SplitJong.TryGetValue(jongChar, out var split))
+ {
+ // 복합 종성: 앞 자음은 종성, 뒷 자음은 새 초성
+ var newCho = ChoIdx(split.Second);
+ if (newCho < 0) newCho = 0;
+ var remainJong = JongIdx(split.First);
+ // 현재 음절 (jong=split.First)
+ _sb.Append(MakeSyllable(_cho, _jung, remainJong));
+ _cho = newCho;
+ _jung = vi;
+ _jong = -1;
+ }
+ else
+ {
+ // 단일 종성 → 새 초성으로
+ var newCho = ChoIdx(jongChar);
+ if (newCho < 0) newCho = 0;
+ _sb.Append(MakeSyllable(_cho, _jung, 0));
+ _cho = newCho;
+ _jung = vi;
+ _jong = -1;
+ }
+ }
+
+ private void FeedConsonant(char c)
+ {
+ var ci = ChoIdx(c);
+
+ if (_cho < 0)
+ {
+ // 처음 자음
+ _cho = ci >= 0 ? ci : 0;
+ return;
+ }
+
+ if (_jung < 0)
+ {
+ // 초성만 있음 → 이전 초성 출력, 새 초성
+ _sb.Append(Choseong[_cho]);
+ _cho = ci >= 0 ? ci : 0;
+ _jung = -1;
+ _jong = -1;
+ return;
+ }
+
+ if (_jong < 0)
+ {
+ // 초성+중성 → 종성 후보
+ _jong = JongIdx(c);
+ if (_jong < 0) _jong = 0; // 종성에 없는 자음은 그냥 처리
+ if (_jong == 0)
+ {
+ // 종성에 들어갈 수 없는 자음(ㄸ, ㅃ, ㅉ)
+ Flush();
+ _cho = ci >= 0 ? ci : 0;
+ _jung = -1;
+ _jong = -1;
+ }
+ return;
+ }
+
+ // 초성+중성+종성 있음
+ var curJongChar = Jongseong[_jong];
+ if (CompoundJong.TryGetValue((curJongChar, c), out var compJ))
+ {
+ // 복합 종성 가능
+ var cji = JongIdx(compJ);
+ if (cji > 0)
+ {
+ _jong = cji;
+ return;
+ }
+ }
+
+ // 복합 종성 불가 → 현재 음절 확정, 새 초성
+ Flush();
+ _cho = ci >= 0 ? ci : 0;
+ _jung = -1;
+ _jong = -1;
+ }
+ }
+
+ // ── 영타→한글 변환 ────────────────────────────────────────────────────────
+
+ private static string EngToKorean(string input)
+ {
+ var composer = new HangulComposer();
+ var sb = new StringBuilder();
+
+ foreach (var ch in input)
+ {
+ if (EngToJamo.TryGetValue(ch, out var jamo))
+ {
+ if (composer.Result.Length > 0 || jamo != '\0')
+ {
+ // 현재 변환 중인 조합기에 피드
+ }
+ composer.Feed(jamo);
+ }
+ else
+ {
+ // 변환 불가 문자 → 조합기 플러시 후 그대로
+ var cur = composer.Result;
+ // 지금까지 쌓인 결과 덤프
+ composer.Feed('\0'); // 플러시 트리거 안 됨 → 직접 Flush
+ // 아래 로직: 조합기는 Flush()로만 비워짐
+ sb.Append(ch);
+ }
+ }
+
+ // 위 로직을 단순화: 문자별 처리
+ return ConvertEngToKor(input);
+ }
+
+ private static string ConvertEngToKor(string input)
+ {
+ // 먼저 자모 문자열로 변환
+ var jamoSeq = new List();
+ foreach (var ch in input)
+ {
+ if (EngToJamo.TryGetValue(ch, out var jamo))
+ jamoSeq.Add(jamo);
+ else
+ jamoSeq.Add(ch); // 변환 불가 문자는 그대로
+ }
+
+ // 자모 시퀀스를 한글 음절로 조합
+ var result = new StringBuilder();
+ var i = 0;
+ while (i < jamoSeq.Count)
+ {
+ var ch = jamoSeq[i];
+
+ // 변환 불가 문자 (공백, 숫자, 특수문자 등)
+ if (!IsKorJamo(ch))
+ {
+ result.Append(ch);
+ i++;
+ continue;
+ }
+
+ // 자모 덩어리 추출
+ var jamoBlock = new List();
+ var j = i;
+ while (j < jamoSeq.Count && IsKorJamo(jamoSeq[j]))
+ jamoBlock.Add(jamoSeq[j++]);
+
+ // 자모 블록을 한글로 조합
+ result.Append(ComposeHangul(jamoBlock));
+ i = j;
+ }
+
+ return result.ToString();
+ }
+
+ private static bool IsKorJamo(char c)
+ {
+ return (c >= 'ㄱ' && c <= 'ㅎ') || (c >= 'ㅏ' && c <= 'ㅣ');
+ }
+
+ private static string ComposeHangul(List jamos)
+ {
+ var sb = new StringBuilder();
+ var idx = 0;
+
+ while (idx < jamos.Count)
+ {
+ var c = jamos[idx];
+
+ if (IsVowel(c))
+ {
+ // 단독 모음 → ㅇ + 모음
+ sb.Append(MakeSyllable(ChoIdx('ㅇ'), JungIdx(c), 0));
+ idx++;
+ continue;
+ }
+
+ // 자음: 초성 후보
+ var cho = c;
+ var choI = ChoIdx(cho);
+ if (choI < 0) { sb.Append(c); idx++; continue; }
+ idx++;
+
+ if (idx >= jamos.Count || IsConsonantOnly(jamos[idx]))
+ {
+ // 단독 초성
+ sb.Append(cho);
+ continue;
+ }
+
+ // 중성
+ var v1 = jamos[idx];
+ var jungI = JungIdx(v1);
+ if (jungI < 0) { sb.Append(cho); continue; }
+ idx++;
+
+ // 복합 모음 시도
+ if (idx < jamos.Count && IsVowel(jamos[idx]))
+ {
+ if (CompoundVowel.TryGetValue((v1, jamos[idx]), out var cv))
+ {
+ jungI = JungIdx(cv);
+ idx++;
+ }
+ }
+
+ // 종성 후보
+ if (idx >= jamos.Count)
+ {
+ sb.Append(MakeSyllable(choI, jungI, 0));
+ continue;
+ }
+
+ var next = jamos[idx];
+ if (IsVowel(next))
+ {
+ sb.Append(MakeSyllable(choI, jungI, 0));
+ continue;
+ }
+
+ // 종성 자음
+ var jongI = JongIdx(next);
+ if (jongI <= 0)
+ {
+ sb.Append(MakeSyllable(choI, jungI, 0));
+ continue;
+ }
+
+ idx++;
+
+ // 다음 모음 있으면 종성→초성 이동
+ if (idx < jamos.Count && IsVowel(jamos[idx]))
+ {
+ // 복합 종성 분리 확인
+ if (idx + 1 < jamos.Count && IsVowel(jamos[idx]))
+ {
+ // 분리 없음: next 자음 → 다음 음절 초성
+ }
+ sb.Append(MakeSyllable(choI, jungI, 0));
+ idx--; // next 자음을 다음 루프에서 초성으로 사용
+ continue;
+ }
+
+ // 복합 종성 시도
+ if (idx < jamos.Count && !IsVowel(jamos[idx]))
+ {
+ var next2 = jamos[idx];
+ var jongChar = Jongseong[jongI];
+ if (CompoundJong.TryGetValue((jongChar, next2), out var cj))
+ {
+ var cji = JongIdx(cj);
+ if (cji > 0)
+ {
+ // 다음에 모음이 있으면 복합 종성 분리
+ if (idx + 1 < jamos.Count && IsVowel(jamos[idx + 1]))
+ {
+ sb.Append(MakeSyllable(choI, jungI, jongI));
+ // next2는 다음 음절 초성으로
+ idx--; // next2를 다시 처리
+ idx++;
+ continue;
+ }
+ jongI = cji;
+ idx++;
+ }
+ }
+
+ // 다음에 모음이 있으면 종성→초성
+ if (idx < jamos.Count && IsVowel(jamos[idx]))
+ {
+ sb.Append(MakeSyllable(choI, jungI, 0));
+ idx -= 2;
+ idx++;
+ continue;
+ }
+ }
+
+ sb.Append(MakeSyllable(choI, jungI, jongI));
+ }
+
+ return sb.ToString();
+ }
+
+ // 초성으로만 사용 가능한 자음인지 (된소리 = 종성 불가)
+ private static bool IsConsonantOnly(char c)
+ {
+ if (!IsKorJamo(c)) return false;
+ if (IsVowel(c)) return false;
+ return JongIdx(c) <= 0;
+ }
+
+ // ── GetItemsAsync ─────────────────────────────────────────────────────────
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ string inputText;
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ // 클립보드에서 읽기
+ string? clipText = null;
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() =>
+ {
+ if (Clipboard.ContainsText())
+ clipText = Clipboard.GetText();
+ });
+ }
+ catch { }
+
+ if (string.IsNullOrWhiteSpace(clipText))
+ {
+ items.Add(new LauncherItem("한/영 타이핑 교정",
+ "fix <영타 텍스트> 또는 클립보드에 텍스트 복사 후 fix 입력",
+ null, null, Symbol: "\uE8AC"));
+ items.Add(new LauncherItem("예: fix gksrmf", "→ 안녕 (영타→한글)", null, null, Symbol: "\uE8AC"));
+ return Task.FromResult>(items);
+ }
+
+ inputText = clipText;
+ }
+ else
+ {
+ inputText = q;
+ }
+
+ var converted = ConvertEngToKor(inputText);
+
+ if (converted == inputText)
+ {
+ items.Add(new LauncherItem("변환할 영타 오류가 없습니다",
+ $"입력: {inputText}", null, null, Symbol: "\uE8AC"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ converted,
+ $"영타 교정 결과 · Enter: 클립보드 복사",
+ null, ("copy", converted), Symbol: "\uE8AC"));
+ items.Add(new LauncherItem(
+ $"원본: {(inputText.Length > 50 ? inputText[..50] + "…" : inputText)}",
+ "", null, null, Symbol: "\uE8AC"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ // ── ExecuteAsync ──────────────────────────────────────────────────────────
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text))
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("타이핑 교정", "교정 결과를 클립보드에 복사했습니다.");
+ }
+ catch { }
+ }
+ return Task.CompletedTask;
+ }
+}
diff --git a/src/AxCopilot/Handlers/LeaveHandler.cs b/src/AxCopilot/Handlers/LeaveHandler.cs
new file mode 100644
index 0000000..fde3a21
--- /dev/null
+++ b/src/AxCopilot/Handlers/LeaveHandler.cs
@@ -0,0 +1,323 @@
+using System.Text.Json;
+using System.Text.Json.Serialization;
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L23-2: 연차·휴가 관리. "leave" 프리픽스로 사용합니다.
+///
+/// 예: leave → 잔여 연차 현황
+/// leave set 15 → 연간 연차 일수 설정
+/// leave use 2026-04-10 → 1일 연차 기록
+/// leave use 2026-04-10 0.5 → 반차 기록
+/// leave use 2026-04-10 0.5 반차메모 → 메모 포함
+/// leave del 2026-04-10 → 기록 삭제
+/// leave remaining → 잔여 연차
+/// leave list → 올해 사용 이력
+/// leave clear → 올해 기록 초기화
+/// Enter → 저장 실행 또는 복사
+/// 저장: %APPDATA%\AxCopilot\leave.json
+///
+public class LeaveHandler : IActionHandler
+{
+ public string? Prefix => "leave";
+
+ public PluginMetadata Metadata => new(
+ "연차 관리",
+ "연차·휴가 기록 — 설정 · 사용 · 잔여 조회 · 이력",
+ "1.0",
+ "AX");
+
+ private static readonly string DataPath = System.IO.Path.Combine(
+ Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData),
+ "AxCopilot", "leave.json");
+
+ private static readonly JsonSerializerOptions JsonOpts = new()
+ {
+ WriteIndented = true,
+ Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping,
+ };
+
+ private sealed class LeaveData
+ {
+ [JsonPropertyName("annualDays")] public double AnnualDays { get; set; } = 15;
+ [JsonPropertyName("records")] public List Records { get; set; } = [];
+ }
+
+ private sealed class LeaveRecord
+ {
+ [JsonPropertyName("date")] public string Date { get; set; } = "";
+ [JsonPropertyName("days")] public double Days { get; set; } = 1;
+ [JsonPropertyName("note")] public string Note { get; set; } = "";
+ }
+
+ // ── GetItemsAsync ─────────────────────────────────────────────────────────
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+ var data = LoadData();
+ var year = DateTime.Today.Year;
+ var used = data.Records.Where(r => r.Date.StartsWith(year.ToString())).Sum(r => r.Days);
+ var left = data.AnnualDays - used;
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem(
+ $"연차 현황 — 연간 {data.AnnualDays}일 사용 {used}일 잔여 {left}일",
+ "leave set N · leave use 날짜 [일수] [메모] · leave list · leave remaining",
+ null, null, Symbol: "\uE716"));
+ items.Add(new LauncherItem("leave set <일수>", "연간 연차 총일수 설정", null, null, Symbol: "\uE716"));
+ items.Add(new LauncherItem("leave use <날짜>", "연차 사용 기록 (기본 1일)", null, null, Symbol: "\uE716"));
+ items.Add(new LauncherItem("leave list", "올해 사용 이력 전체 조회", null, null, Symbol: "\uE716"));
+ items.Add(new LauncherItem("leave remaining", "잔여 연차 확인", null, null, Symbol: "\uE716"));
+ return Task.FromResult>(items);
+ }
+
+ var parts = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var sub = parts[0].ToLowerInvariant();
+
+ // set N
+ if (sub == "set")
+ {
+ if (parts.Length >= 2 && double.TryParse(parts[1], out var n) && n > 0)
+ {
+ items.Add(new LauncherItem(
+ $"연간 연차를 {n}일로 설정",
+ $"현재: {data.AnnualDays}일 → {n}일 · Enter: 저장",
+ null, ("set", n.ToString(System.Globalization.CultureInfo.InvariantCulture)),
+ Symbol: "\uE716"));
+ }
+ else
+ {
+ items.Add(new LauncherItem("일수를 입력하세요", "예: leave set 15", null, null, Symbol: "\uE783"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // use 날짜 [일수] [메모]
+ if (sub == "use")
+ {
+ if (parts.Length < 2)
+ {
+ items.Add(new LauncherItem("날짜를 입력하세요", "예: leave use 2026-04-10", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+ var dateStr = parts[1];
+ if (!DateOnly.TryParseExact(dateStr, new[] { "yyyy-MM-dd", "yyyy/MM/dd" },
+ System.Globalization.CultureInfo.InvariantCulture,
+ System.Globalization.DateTimeStyles.None, out _))
+ {
+ items.Add(new LauncherItem("날짜 형식 오류", "yyyy-MM-dd 형식으로 입력하세요", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+ var useDays = 1.0;
+ var note = "";
+ if (parts.Length >= 3 && double.TryParse(parts[2],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var pd))
+ {
+ useDays = pd;
+ if (parts.Length >= 4)
+ note = string.Join(" ", parts[3..]);
+ }
+ else if (parts.Length >= 3)
+ {
+ note = string.Join(" ", parts[2..]);
+ }
+ var newLeft = left - useDays;
+ var label = note.Length > 0
+ ? $"{dateStr} 연차 {useDays}일 기록 [{note}]"
+ : $"{dateStr} 연차 {useDays}일 기록";
+ items.Add(new LauncherItem(
+ label,
+ $"잔여: {newLeft}일 · Enter: 저장",
+ null, ("use", $"{dateStr}|{useDays.ToString(System.Globalization.CultureInfo.InvariantCulture)}|{note}"),
+ Symbol: "\uE716"));
+ return Task.FromResult>(items);
+ }
+
+ // del 날짜
+ if (sub == "del")
+ {
+ if (parts.Length < 2)
+ {
+ items.Add(new LauncherItem("날짜를 입력하세요", "예: leave del 2026-04-10", null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+ var delDate = parts[1];
+ var target = data.Records.FirstOrDefault(r => r.Date == delDate);
+ if (target == null)
+ {
+ items.Add(new LauncherItem($"{delDate} 기록 없음", "해당 날짜의 연차 기록이 없습니다", null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ items.Add(new LauncherItem(
+ $"{delDate} 기록 삭제 ({target.Days}일{(target.Note.Length > 0 ? " / " + target.Note : "")})",
+ "Enter: 삭제",
+ null, ("del", delDate), Symbol: "\uE716"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // remaining
+ if (sub == "remaining")
+ {
+ items.Add(new LauncherItem(
+ $"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
+ "Enter: 클립보드 복사",
+ null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
+ return Task.FromResult>(items);
+ }
+
+ // list
+ if (sub == "list")
+ {
+ var yearRecords = data.Records
+ .Where(r => r.Date.StartsWith(year.ToString()))
+ .OrderBy(r => r.Date)
+ .ToList();
+ items.Add(new LauncherItem(
+ $"{year}년 연차 사용 이력 — {yearRecords.Count}건 총 {used}일",
+ $"잔여: {left}일", null, null, Symbol: "\uE716"));
+ if (yearRecords.Count == 0)
+ {
+ items.Add(new LauncherItem("사용 이력 없음", "", null, null, Symbol: "\uE716"));
+ }
+ else
+ {
+ foreach (var r in yearRecords)
+ {
+ var noteStr = r.Note.Length > 0 ? $" [{r.Note}]" : "";
+ items.Add(new LauncherItem(
+ $"{r.Date} {r.Days}일{noteStr}",
+ "Enter: 삭제",
+ null, ("del", r.Date), Symbol: "\uE716"));
+ }
+ }
+ return Task.FromResult>(items);
+ }
+
+ // clear
+ if (sub == "clear")
+ {
+ var cnt = data.Records.Count(r => r.Date.StartsWith(year.ToString()));
+ items.Add(new LauncherItem(
+ $"{year}년 연차 기록 초기화 ({cnt}건)",
+ "Enter: 확인",
+ null, ("clear", year.ToString()), Symbol: "\uE716"));
+ return Task.FromResult>(items);
+ }
+
+ // 기본 — 안내
+ items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
+ "leave set · use · del · remaining · list · clear",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ // ── ExecuteAsync ──────────────────────────────────────────────────────────
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ var data = LoadData();
+
+ switch (item.Data)
+ {
+ case ("set", string nStr) when double.TryParse(nStr,
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var n):
+ data.AnnualDays = n;
+ SaveData(data);
+ NotificationService.Notify("연차 관리", $"연간 연차를 {n}일로 설정했습니다.");
+ break;
+
+ case ("use", string payload):
+ {
+ var parts = payload.Split('|');
+ if (parts.Length >= 2 &&
+ double.TryParse(parts[1],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var days))
+ {
+ var dateStr = parts[0];
+ var note = parts.Length >= 3 ? parts[2] : "";
+ // 중복 날짜 처리 — 누적
+ var existing = data.Records.FirstOrDefault(r => r.Date == dateStr);
+ if (existing != null)
+ {
+ existing.Days += days;
+ if (note.Length > 0) existing.Note = note;
+ }
+ else
+ {
+ data.Records.Add(new LeaveRecord { Date = dateStr, Days = days, Note = note });
+ }
+ data.Records.Sort((a, b) => string.Compare(a.Date, b.Date, StringComparison.Ordinal));
+ SaveData(data);
+ NotificationService.Notify("연차 관리", $"{dateStr} 연차 {days}일 기록됐습니다.");
+ }
+ break;
+ }
+
+ case ("del", string delDate):
+ {
+ var removed = data.Records.RemoveAll(r => r.Date == delDate);
+ if (removed > 0)
+ {
+ SaveData(data);
+ NotificationService.Notify("연차 관리", $"{delDate} 기록이 삭제됐습니다.");
+ }
+ break;
+ }
+
+ case ("clear", string yearStr) when int.TryParse(yearStr, out var y):
+ {
+ var cnt = data.Records.RemoveAll(r => r.Date.StartsWith(y.ToString()));
+ SaveData(data);
+ NotificationService.Notify("연차 관리", $"{y}년 연차 기록 {cnt}건 초기화됐습니다.");
+ break;
+ }
+
+ case ("copy", string text):
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("연차 관리", "클립보드에 복사했습니다.");
+ }
+ catch { }
+ break;
+ }
+ return Task.CompletedTask;
+ }
+
+ // ── 저장/불러오기 ─────────────────────────────────────────────────────────
+
+ private static LeaveData LoadData()
+ {
+ try
+ {
+ if (!System.IO.File.Exists(DataPath)) return new LeaveData();
+ var json = System.IO.File.ReadAllText(DataPath, System.Text.Encoding.UTF8);
+ return JsonSerializer.Deserialize(json, JsonOpts) ?? new LeaveData();
+ }
+ catch { return new LeaveData(); }
+ }
+
+ private static void SaveData(LeaveData data)
+ {
+ try
+ {
+ System.IO.Directory.CreateDirectory(System.IO.Path.GetDirectoryName(DataPath)!);
+ System.IO.File.WriteAllText(DataPath,
+ JsonSerializer.Serialize(data, JsonOpts),
+ System.Text.Encoding.UTF8);
+ }
+ catch { }
+ }
+}
diff --git a/src/AxCopilot/Handlers/WorkTimeHandler.cs b/src/AxCopilot/Handlers/WorkTimeHandler.cs
new file mode 100644
index 0000000..cf4d64a
--- /dev/null
+++ b/src/AxCopilot/Handlers/WorkTimeHandler.cs
@@ -0,0 +1,241 @@
+using System.Windows;
+using AxCopilot.SDK;
+using AxCopilot.Services;
+
+namespace AxCopilot.Handlers;
+
+///
+/// L23-3: 근무 시간·급여 계산. "work" 프리픽스로 사용합니다.
+///
+/// 예: work 09:00 18:30 → 근무시간·초과근무 (점심 1시간 자동 제외)
+/// work 09:00 18:30 -30 → 점심 30분 제외
+/// work 09:00 18:30 -0 → 점심 제외 없음
+/// work 09:00 18:30 pay 15000 → 급여 함께 계산
+/// work pay 15000 → 이전 계산 재활용 급여 산출
+/// work week 45.5 → 주간 근무시간 입력 → 초과 계산
+/// Enter → 결과 복사
+///
+public class WorkTimeHandler : IActionHandler
+{
+ public string? Prefix => "work";
+
+ public PluginMetadata Metadata => new(
+ "근무시간 계산",
+ "출퇴근 시간 입력 → 근무시간·초과근무·급여 계산",
+ "1.0",
+ "AX");
+
+ // 마지막 계산 캐시 (pay 재활용용)
+ private static double _lastWorkedHours;
+
+ // ── 파서 ──────────────────────────────────────────────────────────────────
+
+ private static bool TryParseTime(string s, out TimeSpan result)
+ {
+ result = default;
+ s = s.Trim();
+ // HH:mm or H:mm
+ if (s.Contains(':'))
+ {
+ var parts = s.Split(':');
+ if (parts.Length == 2 &&
+ int.TryParse(parts[0], out var h) &&
+ int.TryParse(parts[1], out var m) &&
+ h >= 0 && h <= 47 && m >= 0 && m < 60)
+ {
+ result = new TimeSpan(h, m, 0);
+ return true;
+ }
+ return false;
+ }
+ // HHmm (4자리)
+ if (s.Length == 4 &&
+ int.TryParse(s[..2], out var hh) &&
+ int.TryParse(s[2..], out var mm) &&
+ hh >= 0 && hh <= 23 && mm >= 0 && mm < 60)
+ {
+ result = new TimeSpan(hh, mm, 0);
+ return true;
+ }
+ return false;
+ }
+
+ private static bool TryParseWorkTime(string q,
+ out TimeSpan start, out TimeSpan end,
+ out double lunchMinutes, out double payWage)
+ {
+ start = default;
+ end = default;
+ lunchMinutes = 60;
+ payWage = 0;
+
+ var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ if (tokens.Length < 2) return false;
+ if (!TryParseTime(tokens[0], out start)) return false;
+ if (!TryParseTime(tokens[1], out end)) return false;
+
+ for (var i = 2; i < tokens.Length; i++)
+ {
+ var t = tokens[i];
+ // -N → 점심 제외 분
+ if (t.StartsWith('-') && double.TryParse(t[1..],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var lm))
+ {
+ lunchMinutes = lm;
+ }
+ // pay N
+ else if (t.Equals("pay", StringComparison.OrdinalIgnoreCase) && i + 1 < tokens.Length)
+ {
+ if (double.TryParse(tokens[i + 1],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var pw))
+ {
+ payWage = pw;
+ i++;
+ }
+ }
+ }
+ return true;
+ }
+
+ private static (double stdPay, double otPay, double total) CalcPay(double wage, double workedHours)
+ {
+ var stdHours = Math.Min(workedHours, 8.0);
+ var otHours = Math.Max(0, workedHours - 8.0);
+ var stdPay = stdHours * wage;
+ var otPay = otHours * wage * 1.5;
+ return (stdPay, otPay, stdPay + otPay);
+ }
+
+ // ── GetItemsAsync ─────────────────────────────────────────────────────────
+
+ public Task> GetItemsAsync(string query, CancellationToken ct)
+ {
+ var q = query.Trim();
+ var items = new List();
+
+ if (string.IsNullOrWhiteSpace(q))
+ {
+ items.Add(new LauncherItem("근무시간 계산기",
+ "work 09:00 18:30 → 근무시간 / work 09:00 18:30 -30 → 점심 30분 / work 09:00 18:30 pay 15000 → 급여",
+ null, null, Symbol: "\uE916"));
+ items.Add(new LauncherItem("work 09:00 18:30", "점심 1시간 자동 제외", null, null, Symbol: "\uE916"));
+ items.Add(new LauncherItem("work 09:00 18:30 -30", "점심 30분 제외", null, null, Symbol: "\uE916"));
+ items.Add(new LauncherItem("work pay 15000", "이전 계산 재활용 급여 산출", null, null, Symbol: "\uE916"));
+ return Task.FromResult>(items);
+ }
+
+ var tokens = q.Split(' ', StringSplitOptions.RemoveEmptyEntries);
+ var sub = tokens[0].ToLowerInvariant();
+
+ // pay N → 이전 계산 재활용
+ if (sub == "pay")
+ {
+ if (tokens.Length >= 2 && double.TryParse(tokens[1],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var wage))
+ {
+ if (_lastWorkedHours <= 0)
+ {
+ items.Add(new LauncherItem("이전 계산 없음", "먼저 시간을 계산하세요: work HH:mm HH:mm",
+ null, null, Symbol: "\uE783"));
+ }
+ else
+ {
+ var (stdPay, otPay, total) = CalcPay(wage, _lastWorkedHours);
+ var result = $"근무 {_lastWorkedHours:F1}시간 | 시급 {wage:#,0}원 | 기본 {stdPay:#,0}원 | 초과 {otPay:#,0}원 | 합계 {total:#,0}원";
+ items.Add(new LauncherItem(
+ $"급여: {total:#,0}원",
+ $"기본 {stdPay:#,0}원 + 초과(1.5배) {otPay:#,0}원 · Enter: 복사",
+ null, ("copy", result), Symbol: "\uE916"));
+ }
+ }
+ else
+ {
+ items.Add(new LauncherItem("시급을 입력하세요", "예: work pay 15000", null, null, Symbol: "\uE783"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // week N → 주간 근무시간
+ if (sub == "week")
+ {
+ if (tokens.Length >= 2 && double.TryParse(tokens[1],
+ System.Globalization.NumberStyles.Any,
+ System.Globalization.CultureInfo.InvariantCulture, out var weekHours))
+ {
+ var std = 40.0;
+ var ot = Math.Max(0, weekHours - std);
+ var label = $"주간 근무 {weekHours:F1}시간 초과근무 {ot:F1}시간 (기준 {std}h)";
+ items.Add(new LauncherItem(label, "Enter: 복사",
+ null, ("copy", label), Symbol: "\uE916"));
+ }
+ else
+ {
+ items.Add(new LauncherItem("시간을 입력하세요", "예: work week 45.5", null, null, Symbol: "\uE783"));
+ }
+ return Task.FromResult>(items);
+ }
+
+ // 시각 파싱 시도
+ if (!TryParseWorkTime(q, out var start, out var end, out var lunch, out var pay))
+ {
+ items.Add(new LauncherItem("시간 형식 오류",
+ "예: work 09:00 18:30 또는 work 09:00 18:30 -30 pay 15000",
+ null, null, Symbol: "\uE783"));
+ return Task.FromResult>(items);
+ }
+
+ // 야간 처리
+ if (end <= start) end = end.Add(TimeSpan.FromHours(24));
+
+ var totalSpan = end - start - TimeSpan.FromMinutes(lunch);
+ var workedHours = totalSpan.TotalHours;
+ if (workedHours < 0) workedHours = 0;
+
+ _lastWorkedHours = workedHours;
+
+ var wh = (int)workedHours;
+ var wm = (int)Math.Round((workedHours - wh) * 60);
+ var otHours = Math.Max(0, workedHours - 8.0);
+
+ var summaryLine = $"근무 {workedHours:F1}시간 ({wh}시간 {wm}분) 초과 {otHours:F1}시간";
+ items.Add(new LauncherItem(
+ $"근무시간: {workedHours:F1}시간 ({wh}시간 {wm}분)",
+ $"초과근무: {otHours:F1}시간 · Enter: 복사",
+ null, ("copy", summaryLine), Symbol: "\uE916"));
+ items.Add(new LauncherItem(
+ $"초과근무: {otHours:F1}시간",
+ $"기준 8시간 초과분 / 점심 제외: {lunch}분",
+ null, ("copy", $"초과근무: {otHours:F1}시간"), Symbol: "\uE916"));
+
+ if (pay > 0)
+ {
+ var (stdP, otP, totalP) = CalcPay(pay, workedHours);
+ var payLine = $"급여: {totalP:#,0}원 (기본 {stdP:#,0}원 + 초과 {otP:#,0}원) | 시급 {pay:#,0}원";
+ items.Add(new LauncherItem(
+ $"급여: {totalP:#,0}원",
+ $"기본 {stdP:#,0}원 + 초과(1.5배) {otP:#,0}원 · Enter: 복사",
+ null, ("copy", payLine), Symbol: "\uE916"));
+ }
+
+ return Task.FromResult>(items);
+ }
+
+ // ── ExecuteAsync ──────────────────────────────────────────────────────────
+
+ public Task ExecuteAsync(LauncherItem item, CancellationToken ct)
+ {
+ if (item.Data is ("copy", string text))
+ {
+ try
+ {
+ Application.Current.Dispatcher.Invoke(() => Clipboard.SetText(text));
+ NotificationService.Notify("근무시간", "클립보드에 복사했습니다.");
+ }
+ catch { }
+ }
+ return Task.CompletedTask;
+ }
+}