Files
AX-Copilot/src/AxCopilot/Handlers/LeaveHandler.cs
lacvet babe593482 [Phase L23] 한국 사무 환경 특화 도구 4종 구현
CalHandler (cal 프리픽스, 205줄):
- 2024~2027 한국 공휴일 딕셔너리 (DateOnly → string, 66개 날짜)
- cal: 이번달 달력·공휴일 / cal next: 다음 공휴일 5개 D-N일
- cal workdays: 이번달 업무일·잔여 / cal today: 오늘 공휴일 여부
- cal yyyy-MM: 특정 월 / cal yyyy-MM-dd: 특정 날짜 조회

LeaveHandler (leave 프리픽스, 185줄):
- %APPDATA%\AxCopilot\leave.json 로컬 저장 (LeaveData/LeaveRecord 모델)
- set/use/del/remaining/list/clear 서브커맨드
- 반차(0.5일) 지원, 잔여 연차 실시간 계산

WorkTimeHandler (work 프리픽스, 160줄):
- work 09:00 18:30: 근무시간·초과근무 (점심 1시간 자동 제외)
- work 09:00 18:30 -30: 점심 N분 지정 / -0: 미적용
- work 09:00 18:30 pay 15000: 시급 기준 급여 산출 (초과 1.5배)
- work week N: 주간 40h 기준 초과 계산 / _lastWorkedHours 캐시

FixHandler (fix 프리픽스, 220줄):
- 두벌식 영→자모 매핑 (소문자+Shift 된소리/쌍모음)
- HangulComposer 상태 기계: 초성19·중성21·종성28 + 복합모음7·복합종성11
- 빈 쿼리 시 클립보드 자동 교정
- 예: fix gksrmf → 안녕

App.xaml.cs: 4개 핸들러 등록 추가
빌드: 경고 0, 오류 0

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-04 18:18:30 +09:00

324 lines
14 KiB
C#

using System.Text.Json;
using System.Text.Json.Serialization;
using System.Windows;
using AxCopilot.SDK;
using AxCopilot.Services;
namespace AxCopilot.Handlers;
/// <summary>
/// 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
/// </summary>
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<LeaveRecord> 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<IEnumerable<LauncherItem>> GetItemsAsync(string query, CancellationToken ct)
{
var q = query.Trim();
var items = new List<LauncherItem>();
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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// remaining
if (sub == "remaining")
{
items.Add(new LauncherItem(
$"잔여 연차: {left}일 (연간 {data.AnnualDays}일 - 사용 {used}일)",
"Enter: 클립보드 복사",
null, ("copy", $"잔여 연차: {left}일"), Symbol: "\uE716"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(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<IEnumerable<LauncherItem>>(items);
}
// 기본 — 안내
items.Add(new LauncherItem($"'{q}' — 알 수 없는 명령",
"leave set · use · del · remaining · list · clear",
null, null, Symbol: "\uE783"));
return Task.FromResult<IEnumerable<LauncherItem>>(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<LeaveData>(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 { }
}
}