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>
324 lines
14 KiB
C#
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 { }
|
|
}
|
|
}
|