diff --git a/AGENTS.md b/AGENTS.md index 1241805..6b92974 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -48,7 +48,8 @@ Run `tp --help` for full command list. Key commands: - `tp ts create ...` - Create timesheet - `tp ts check --week --json` - Leave-aware weekly coverage check (see below) - `tp project recent` - Surface projects recently logged against (likely picks for new entries) -- `tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 --note "..." --approved-by "email" --cc "e1,e2" --yes` - Create leave +- `tp leave create --start 2026-03-30 --end 2026-03-30 --type 1 --note "..." --approved-by "email" --cc "e1,e2" --yes` - Create leave (`--dry-run --json` validates without writing) +- `tp leave update ID --start 2026-04-01 --end 2026-04-01 --note "..." --yes` - Update leave while preserving omitted API-returned fields (`--dry-run --json` previews the full payload) - `tp leave cancel ID --reason "..." --yes` - Cancel leave - `tp leave list --filter UPCOMING --json` - List leave - `tp leave balance --emp-id JEK` - Leave-usage signal (days since last leave + hours taken in last 12 months) @@ -82,9 +83,22 @@ The leave create endpoint (`POST /api/leave/`) requires these fields in the requ - **Required**: `RequestedEmpId`, `StartDate` (DateTimeOffset), `EndDate` (DateTimeOffset), `LeaveTypeId`, `UserStartTime`, `UserEndTime`, `AllDay` - **Optional**: `Note`, `OptionalEmp` (CC emails), `ApprovedBy` (email), `TimeLessOverride` +The leave update endpoint (`PUT /api/leave/`) uses the same full payload plus `Id`. +It is a replacement operation, not a patch. All CLI and MCP updates must go through +`LeaveUpdateService`, which reads the existing request and preserves omitted API-returned fields +before calling `UpdateLeaveAsync`. + +Older leave-list responses omit `UserStartTime` and `UserEndTime`. Updates preserve +those values when returned; otherwise they use the current employee profile values and +then the 09:00-18:00 defaults. Callers can pass explicit workday times when required. + +CLI and MCP leave create/update surfaces support dry-run. Dry-run performs the same +validation and payload preparation, returns the proposed request, and must not call +`CreateLeaveAsync` or `UpdateLeaveAsync`. + The cancel endpoint (`PUT /api/leave/{id}/cancel`) requires `LeaveId` (Guid) and `CancellationReason` in the request body. -The list endpoint (`GET /api/leave/`) returns per-entry `daysAway`, `updatedAt`, `timeLessOverride`, `cancellationReason` (all bound on `LeaveEntry`) plus a top-level `cancelledCount` on the list envelope. These surface in `tp leave list --json`. +The list endpoint (`GET /api/leave/`) returns per-entry `daysAway`, `updatedAt`, `optionalEmp`, `timeLessOverride`, `cancellationReason` (all bound on `LeaveEntry`) plus a top-level `cancelledCount` on the list envelope. These surface in `tp leave list --json`. ## Testing diff --git a/README.md b/README.md index f20b985..18a3d91 100644 --- a/README.md +++ b/README.md @@ -12,7 +12,7 @@ SSW TimePro is a time tracking and invoicing system. This CLI makes it fast to v - **Timesheet CRUD** — Create, update, delete timesheets with rate checking and lock detection - **Suggested Timesheets** — View and accept suggested timesheets to keep accuracy stats high - **CRM Bookings** — See your appointments from the CRM calendar -- **Leave Management** — Create, list, and cancel EasyLeave requests +- **Leave Management** — Create, list, update, and cancel EasyLeave requests - **Repo Mapping** — Map git repos to clients/projects; auto-detects via path or remote URL, with git worktree support; optional `--issues-repo` for projects whose issues live in a different GitHub repo than the code - **Daily Scrum** — Generate an SSW-format daily scrum email from timesheets, CRM bookings and GitHub activity, with AutoScrum-inspired `--smart` selection, overridable per-tenant/client templates, rich-text / markdown / plain clipboard support and an interactive copy mode - **Location Defaults** — Set WFH days so location is auto-applied when creating timesheets @@ -129,7 +129,8 @@ tp ts get 2026-03-12 # Specific date | `tp bk list` | List CRM bookings/appointments | | `tp leave list` | List leave entries (`--filter UPCOMING\|PAST`) | | `tp leave balance` | Show leave-usage signal (`--emp-id`): days since last leave + hours taken in last 12 months | -| `tp leave create` | Create a leave request (see options below) | +| `tp leave create` | Create a leave request (see options and `--dry-run` below) | +| `tp leave update ID` | Update a leave request while preserving unspecified API-returned fields; supports `--dry-run` | | `tp leave cancel ID` | Cancel a leave request (`--reason`) | | `tp cl search QUERY` | Search for clients | | `tp proj list --client ID` | List projects for a client | @@ -171,7 +172,7 @@ tp ts get 2026-03-12 # Specific date | `tp recurring list / get` | Recurring invoice templates | | `tp prepaid summary / status INVOICE_ID` | Prepaid drawdown totals / PDF report | -All read commands support `--json` for machine-readable output. All write commands support `--yes` to skip confirmation prompts. +All read commands support `--json` for machine-readable output. All write commands support `--yes` to skip confirmation prompts. Leave create and update also support `--dry-run`, which validates and returns the proposed request without writing it. **`--json` error contract:** on failure, the command emits a structured envelope to **stdout** so stdout stays valid JSON even when an API call fails — `{"error":{"code":,"message":"...","detail":}}` (all keys always present) — and exits non-zero. Human-readable error/warning text always goes to **stderr**, so it never corrupts the JSON stream. @@ -233,6 +234,14 @@ tp leave create --start 2026-03-30 --end 2026-03-30 --type "Annual Leave" \ --approved-by "approver@northwind.example" \ --cc "notify1@northwind.example,notify2@northwind.example" --yes +# Move an existing request; omitted API-returned fields are preserved +tp leave update --start 2026-04-01 --end 2026-04-01 \ + --note "Updated travel date" --yes + +# Validate the same update and inspect the complete payload without applying it +tp leave update --start 2026-04-01 --end 2026-04-01 \ + --note "Updated travel date" --dry-run --json + # Cancel a leave request tp leave cancel --reason "Plans changed" --yes ``` @@ -247,6 +256,16 @@ Leave create options: Leave create uses `--timezone` first when supplied, then the TimePro user profile timezone when it is set. If neither is set, the CLI/MCP host uses the machine timezone as the browser-equivalent fallback; agents can control that by choosing the environment used to launch `tp`. +Leave update accepts the same date, type, note, approver, CC, day-mode, workday-time, +and timezone fields. It reads the current request first and preserves every API-returned +field that was not explicitly changed. Stored workday times are preserved when TimePro +returns them; older list responses omit them, so update falls back to the current profile +times and then 09:00-18:00. Use `--start-time` and `--end-time` when those values must be +explicit. Use `--clear-approved-by` or `--clear-cc` to remove those values, and +`--half-day` / `--full-day` to change the day mode. Both create and update support +`--dry-run`; combine it with `--json` to inspect the exact API payload without creating +or changing leave. + ### Week View Compact view shows one line per timesheet with totals: @@ -550,7 +569,7 @@ Current default tool groups include: |-------|----------| | Timesheets | Get, create, update, delete, suggested timesheets, accept suggestions, list iterations, `check_week` (leave-aware weekly coverage) | | Lookup | Search clients, list projects, get client rate, CRM bookings, location and repo mapping | -| Leave | List EasyLeave entries (optionally filtered by `empId`), create EasyLeave requests using timezone override/profile/machine fallback, `get_leave_balance` (days since last leave + 12-month hours) | +| Leave | List EasyLeave entries (optionally filtered by `empId`), create and safely update EasyLeave requests with dry-run previews, `get_leave_balance` (days since last leave + 12-month hours) | Optional accounting MCP tools are enabled with: @@ -588,6 +607,7 @@ Then ask Claude things like: - "Create a timesheet for today — I worked on the Northwind Traders app" - "Accept the suggested timesheet for Monday" - "What's my billing rate for Northwind?" +- "Move my upcoming leave to Wednesday and keep its other details" ### VS Code (Copilot / Continue) @@ -655,7 +675,7 @@ TimePro.Tools/ │ │ ├── Tenants/ # set, info, list │ │ ├── Timesheets/ # get, create, update, delete, suggest, accept, export, check, copy │ │ ├── Bookings/ # list -│ │ ├── Leave/ # list, create, cancel +│ │ ├── Leave/ # list, create, update, cancel │ │ ├── Clients/ # search, outstanding, billable-work │ │ ├── Projects/ # list │ │ ├── Iterations/ # list diff --git a/release-notes/0.2.6.md b/release-notes/0.2.6.md new file mode 100644 index 0000000..8d249f3 --- /dev/null +++ b/release-notes/0.2.6.md @@ -0,0 +1,8 @@ +# 0.2.6 + +- Added `tp leave update ` for changing an EasyLeave request while preserving + fields returned by TimePro that were not explicitly changed. +- Added MCP leave-update support, so agents can safely change dates, type, note, + approver, CC recipients, or day mode on an existing request. +- Added dry-run previews for CLI and MCP leave creation and updates, including the + complete validated request without creating or changing leave in TimePro. diff --git a/release-notes/latest.md b/release-notes/latest.md index 209c538..9abdeda 120000 --- a/release-notes/latest.md +++ b/release-notes/latest.md @@ -1 +1 @@ -0.2.5.md \ No newline at end of file +0.2.6.md \ No newline at end of file diff --git a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs index 0aa8e25..e4fd89d 100644 --- a/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs +++ b/src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs @@ -1,7 +1,6 @@ using System.ComponentModel; using SSW.TimePro.Cli.Infrastructure.ApiClient; using SSW.TimePro.Cli.Infrastructure.Output; -using SSW.TimePro.Cli.Shared.Models; using Spectre.Console; using Spectre.Console.Cli; @@ -10,7 +9,7 @@ namespace SSW.TimePro.Cli.Features.Leave; [Description("Create a leave request")] public class CreateCommand : AsyncCommand { - private readonly ITimeProApiClient _api; + private readonly LeaveCreateService _createService; private readonly ITenantProvider _tenantProvider; public class Settings : CommandSettings @@ -59,120 +58,66 @@ public class Settings : CommandSettings [Description("Skip confirmation")] public bool Yes { get; set; } + [CommandOption("--dry-run")] + [Description("Validate and preview the request without creating leave")] + public bool DryRun { get; set; } + [CommandOption("--json")] [Description("Output as JSON")] public bool Json { get; set; } } - public CreateCommand(ITimeProApiClient api, ITenantProvider tenantProvider) + public CreateCommand(LeaveCreateService createService, ITenantProvider tenantProvider) { - _api = api; + _createService = createService; _tenantProvider = tenantProvider; } protected override async Task ExecuteAsync(CommandContext context, Settings settings, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(settings.Start) || string.IsNullOrEmpty(settings.End) || string.IsNullOrEmpty(settings.Type)) - { - OutputHelper.WriteError("--start, --end, and --type are required"); - return 1; - } - - if (string.IsNullOrWhiteSpace(settings.Note)) - { - WriteValidationError(settings.Json, "--note is required: a reason/description is mandatory for leave"); - return 1; - } - var tenant = _tenantProvider.GetCurrentTenant(); if (tenant is null || string.IsNullOrEmpty(tenant.EmployeeId)) { - OutputHelper.WriteError("No active tenant or employee ID configured. Run 'tp login --tenant ' first."); + WriteValidationError( + settings.Json, + "No active tenant or employee ID configured. Run 'tp login --tenant ' first."); return 1; } try { - var settingsDetails = string.IsNullOrWhiteSpace(settings.TimeZoneId) - ? await _api.GetEmployeeSettingsAsync(cancellationToken) - : null; - if (!LeaveRequestParser.TryResolveRequestTimeZone(settings.TimeZoneId, settingsDetails, out var requestTimeZone, out var timeZoneError)) + var plan = await _createService.PrepareAsync( + tenant.EmployeeId, + new LeaveCreateOptions( + Start: settings.Start, + End: settings.End, + Type: settings.Type, + Note: settings.Note, + ApprovedBy: settings.ApprovedBy, + Cc: settings.OptionalEmp, + HalfDay: settings.HalfDay, + StartTime: settings.StartTime, + EndTime: settings.EndTime, + TimeZoneId: settings.TimeZoneId), + cancellationToken); + + if (!settings.Json && (!settings.Yes || settings.DryRun)) { - WriteValidationError(settings.Json, timeZoneError ?? "Invalid leave request timezone"); - return 1; - } - - if (!LeaveRequestParser.TryParseDateRange( - settings.Start, - settings.End, - requestTimeZone, - out var startDate, - out var endDate, - out var dateError)) - { - WriteValidationError(settings.Json, dateError ?? "Invalid leave date range"); - return 1; - } - - if (IsWeekend(startDate) || IsWeekend(endDate)) - { - // LeaveCommandValidator rejects weekend boundaries; surface it before the API call. - WriteValidationError(settings.Json, "Leave start and end dates must be weekdays"); - return 1; - } - - // Resolve type ID - var leaveTypeId = await ResolveLeaveTypeAsync(settings.Type); - if (leaveTypeId is null) - { - OutputHelper.WriteError($"Unknown leave type: '{settings.Type}'. Use 'tp leave types' or a numeric ID."); - var types = await _api.GetLeaveTypesAsync(CancellationToken.None); - foreach (var t in types.Where(t => t.IsActive)) - AnsiConsole.MarkupLine($" {t.Id}: {Markup.Escape(t.Name)}"); - return 1; + RenderPreview(plan, settings.DryRun); + if (!settings.Yes && !settings.DryRun && !AnsiConsole.Confirm("Submit this leave request?")) + return 1; } - var allDay = !settings.HalfDay; - var userStartTime = LeaveRequestParser.NormalizeTime(settings.StartTime, LeaveRequestParser.DefaultStartTime); - var userEndTime = LeaveRequestParser.NormalizeTime(settings.EndTime, LeaveRequestParser.DefaultEndTime); - var optionalEmps = LeaveRequestParser.ParseOptionalEmployees(settings.OptionalEmp); - - if (!settings.Yes && !settings.Json) + if (settings.DryRun) { - AnsiConsole.MarkupLine("[bold]Creating leave request:[/]"); - AnsiConsole.MarkupLine($" Employee: {tenant.EmployeeId}"); - AnsiConsole.MarkupLine($" Start: {startDate:yyyy-MM-dd}"); - AnsiConsole.MarkupLine($" End: {endDate:yyyy-MM-dd}"); - AnsiConsole.MarkupLine($" Type: {settings.Type}"); - AnsiConsole.MarkupLine($" All day: {allDay}"); - if (!string.IsNullOrEmpty(settings.Note)) - AnsiConsole.MarkupLine($" Note: {Markup.Escape(settings.Note)}"); - if (!string.IsNullOrEmpty(settings.ApprovedBy)) - AnsiConsole.MarkupLine($" Approved by: {Markup.Escape(settings.ApprovedBy)}"); - if (optionalEmps.Count > 0) - AnsiConsole.MarkupLine($" CC: {Markup.Escape(string.Join(", ", optionalEmps))}"); - AnsiConsole.WriteLine(); - - if (!AnsiConsole.Confirm("Submit this leave request?")) - return 1; + if (settings.Json) + OutputHelper.WriteJson(new { dryRun = true, request = plan.Request }); + else + OutputHelper.WriteInfo("Dry run: request validated; no leave request was created"); + return 0; } - var request = new CreateLeaveRequest - { - RequestedEmpId = tenant.EmployeeId, - StartDate = startDate.ToString("o"), - EndDate = endDate.ToString("o"), - LeaveTypeId = leaveTypeId.Value, - Note = settings.Note.Trim(), - UserStartTime = userStartTime, - UserEndTime = userEndTime, - AllDay = allDay, - OptionalEmp = optionalEmps, - ApprovedBy = settings.ApprovedBy, - TimeLessOverride = null - }; - - await _api.CreateLeaveAsync(request, CancellationToken.None); + await _createService.ApplyAsync(plan, cancellationToken); if (settings.Json) OutputHelper.WriteJson(new { success = true }); @@ -181,6 +126,11 @@ protected override async Task ExecuteAsync(CommandContext context, Settings return 0; } + catch (LeaveCreateValidationException ex) + { + WriteValidationError(settings.Json, ex.Message); + return 1; + } catch (ApiException ex) { // Surface the server's response body so failures (incl. 500s) are diagnosable. @@ -193,24 +143,29 @@ protected override async Task ExecuteAsync(CommandContext context, Settings } } + private static void RenderPreview(LeaveCreatePlan plan, bool dryRun) + { + var request = plan.Request; + AnsiConsole.MarkupLine(dryRun + ? "[bold]Proposed leave request (dry run):[/]" + : "[bold]Creating leave request:[/]"); + AnsiConsole.MarkupLine($" Employee: {Markup.Escape(request.RequestedEmpId)}"); + AnsiConsole.MarkupLine($" Start: {Markup.Escape(request.StartDate)}"); + AnsiConsole.MarkupLine($" End: {Markup.Escape(request.EndDate)}"); + AnsiConsole.MarkupLine($" Type: {request.LeaveTypeId} ({Markup.Escape(plan.TypeLabel)})"); + AnsiConsole.MarkupLine($" Note: {Markup.Escape(request.Note ?? "")}"); + AnsiConsole.MarkupLine($" Workday: {Markup.Escape(request.UserStartTime)}-{Markup.Escape(request.UserEndTime)}"); + AnsiConsole.MarkupLine($" All day: {request.AllDay}"); + AnsiConsole.MarkupLine($" CC: {Markup.Escape(string.Join(", ", request.OptionalEmp))}"); + AnsiConsole.MarkupLine($" Approved by: {Markup.Escape(request.ApprovedBy ?? "")}"); + AnsiConsole.MarkupLine($" Time-less override: {(request.TimeLessOverride?.ToString() ?? "")}"); + AnsiConsole.WriteLine(); + } + private static void WriteValidationError(bool json, string message) { if (json) OutputHelper.WriteJsonError(message); OutputHelper.WriteError(message); } - - private static bool IsWeekend(DateTimeOffset date) => - date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; - - private async Task ResolveLeaveTypeAsync(string typeInput) - { - if (int.TryParse(typeInput, out var id)) - return id; - - var types = await _api.GetLeaveTypesAsync(CancellationToken.None); - var match = types.FirstOrDefault(t => - t.Name.Equals(typeInput, StringComparison.OrdinalIgnoreCase)); - return match?.Id; - } } diff --git a/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs b/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs new file mode 100644 index 0000000..7ef7a37 --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Leave/LeaveCreateService.cs @@ -0,0 +1,123 @@ +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Shared.Models; + +namespace SSW.TimePro.Cli.Features.Leave; + +public sealed record LeaveCreateOptions( + string Start, + string End, + string Type, + string? Note, + string? ApprovedBy = null, + string? Cc = null, + bool HalfDay = false, + string? StartTime = null, + string? EndTime = null, + string? TimeZoneId = null); + +public sealed record LeaveCreatePlan(CreateLeaveRequest Request, string TypeLabel); + +public sealed class LeaveCreateValidationException(string message) : Exception(message); + +/// +/// Validates leave-create input and prepares the complete API request shared by CLI and MCP. +/// +public sealed class LeaveCreateService +{ + private readonly ITimeProApiClient _api; + + public LeaveCreateService(ITimeProApiClient api) => _api = api; + + public async Task PrepareAsync( + string employeeId, + LeaveCreateOptions options, + CancellationToken ct = default) + { + if (string.IsNullOrWhiteSpace(options.Start) + || string.IsNullOrWhiteSpace(options.End) + || string.IsNullOrWhiteSpace(options.Type)) + { + throw new LeaveCreateValidationException("--start, --end, and --type are required"); + } + + if (string.IsNullOrWhiteSpace(options.Note)) + { + throw new LeaveCreateValidationException( + "--note is required: a reason/description is mandatory for leave"); + } + + var employeeSettings = string.IsNullOrWhiteSpace(options.TimeZoneId) + ? await _api.GetEmployeeSettingsAsync(ct) + : null; + if (!LeaveRequestParser.TryResolveRequestTimeZone( + options.TimeZoneId, + employeeSettings, + out var requestTimeZone, + out var timeZoneError)) + { + throw new LeaveCreateValidationException( + timeZoneError ?? "Invalid leave request timezone"); + } + + if (!LeaveRequestParser.TryParseDateRange( + options.Start, + options.End, + requestTimeZone, + out var startDate, + out var endDate, + out var dateError)) + { + throw new LeaveCreateValidationException(dateError ?? "Invalid leave date range"); + } + + if (startDate > endDate) + throw new LeaveCreateValidationException("Leave start date must not be after the end date"); + + if (IsWeekend(startDate) || IsWeekend(endDate)) + throw new LeaveCreateValidationException("Leave start and end dates must be weekdays"); + + if (options.HalfDay && startDate.Date != endDate.Date) + throw new LeaveCreateValidationException("Partial-day leave must start and end on the same day"); + + var leaveTypeId = await ResolveLeaveTypeAsync(options.Type, ct); + if (leaveTypeId is null) + throw new LeaveCreateValidationException($"Unknown leave type: '{options.Type}'."); + + var request = new CreateLeaveRequest + { + RequestedEmpId = employeeId, + StartDate = startDate.ToString("o"), + EndDate = endDate.ToString("o"), + LeaveTypeId = leaveTypeId.Value, + Note = options.Note.Trim(), + UserStartTime = LeaveRequestParser.NormalizeTime( + options.StartTime, + LeaveRequestParser.DefaultStartTime), + UserEndTime = LeaveRequestParser.NormalizeTime( + options.EndTime, + LeaveRequestParser.DefaultEndTime), + AllDay = !options.HalfDay, + OptionalEmp = LeaveRequestParser.ParseOptionalEmployees(options.Cc), + ApprovedBy = options.ApprovedBy?.Trim(), + TimeLessOverride = null + }; + + return new LeaveCreatePlan(request, options.Type); + } + + public Task ApplyAsync(LeaveCreatePlan plan, CancellationToken ct = default) => + _api.CreateLeaveAsync(plan.Request, ct); + + private async Task ResolveLeaveTypeAsync(string typeInput, CancellationToken ct) + { + if (int.TryParse(typeInput, out var id)) + return id; + + var types = await _api.GetLeaveTypesAsync(ct); + return types.FirstOrDefault(type => + type.Name.Equals(typeInput, StringComparison.OrdinalIgnoreCase))?.Id; + } + + private static bool IsWeekend(DateTimeOffset date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; +} diff --git a/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs b/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs new file mode 100644 index 0000000..78f9b43 --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Leave/LeaveUpdateService.cs @@ -0,0 +1,231 @@ +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Shared.Models; + +namespace SSW.TimePro.Cli.Features.Leave; + +public sealed record LeaveUpdateOptions( + string? Start = null, + string? End = null, + string? Type = null, + string? Note = null, + string? ApprovedBy = null, + bool ClearApprovedBy = false, + string? Cc = null, + bool ClearCc = false, + bool? AllDay = null, + string? StartTime = null, + string? EndTime = null, + string? TimeZoneId = null); + +public sealed record LeaveUpdatePlan( + LeaveEntry Existing, + UpdateLeaveRequest Request, + IReadOnlyList Changes); + +public sealed class LeaveUpdateValidationException(string message) : Exception(message); + +/// +/// Builds a complete leave update payload while preserving API-returned fields omitted by the caller. +/// TimePro's update endpoint replaces the full leave record rather than applying a patch. Stored +/// workday times are preferred when available; current profile times are the fallback because older +/// list responses omit those fields. +/// +public sealed class LeaveUpdateService +{ + private const int PageSize = 100; + private readonly ITimeProApiClient _api; + + public LeaveUpdateService(ITimeProApiClient api) => _api = api; + + public async Task PrepareAsync( + string leaveId, + string employeeId, + LeaveUpdateOptions options, + CancellationToken ct = default) + { + if (!Guid.TryParse(leaveId, out var parsedLeaveId)) + throw new LeaveUpdateValidationException("Leave ID must be a valid GUID"); + + if (options.ClearApprovedBy && options.ApprovedBy is not null) + throw new LeaveUpdateValidationException("Use either approvedBy or clearApprovedBy, not both"); + + if (options.ClearCc && options.Cc is not null) + throw new LeaveUpdateValidationException("Use either cc or clearCc, not both"); + + var normalizedLeaveId = parsedLeaveId.ToString(); + var existing = await FindLeaveAsync(normalizedLeaveId, employeeId, ct) + ?? throw new LeaveUpdateValidationException( + $"Leave {normalizedLeaveId} was not found for employee {employeeId}"); + + var changes = DescribeChanges(options); + if (changes.Count == 0) + throw new LeaveUpdateValidationException( + "No changes specified. Use --start, --end, --type, --note, --approved-by, --cc, --half-day, --full-day, --start-time, or --end-time."); + + var employeeSettings = await _api.GetEmployeeSettingsAsync(ct); + if (!LeaveRequestParser.TryResolveRequestTimeZone( + options.TimeZoneId, + employeeSettings, + out var requestTimeZone, + out var timeZoneError)) + { + throw new LeaveUpdateValidationException( + timeZoneError ?? "Invalid leave request timezone"); + } + + var startInput = options.Start ?? existing.StartDate; + var endInput = options.End ?? existing.EndDate; + + // Changing between partial-day and full-day should reset the range to whole local dates. + if (options.AllDay is not null) + { + startInput ??= existing.StartDateLocal; + endInput ??= existing.EndDateLocal; + if (options.Start is null) + startInput = DatePart(existing.StartDateLocal ?? existing.StartDate); + if (options.End is null) + endInput = DatePart(existing.EndDateLocal ?? existing.EndDate); + } + + if (string.IsNullOrWhiteSpace(startInput) || string.IsNullOrWhiteSpace(endInput)) + throw new LeaveUpdateValidationException("The existing leave does not contain a complete date range"); + + if (!LeaveRequestParser.TryParseDateRange( + startInput, + endInput, + requestTimeZone, + out var startDate, + out var endDate, + out var dateError)) + { + throw new LeaveUpdateValidationException(dateError ?? "Invalid leave date range"); + } + + if (startDate > endDate) + throw new LeaveUpdateValidationException("Leave start date must not be after the end date"); + + if (IsWeekend(startDate) || IsWeekend(endDate)) + throw new LeaveUpdateValidationException("Leave start and end dates must be weekdays"); + + var allDay = options.AllDay ?? existing.AllDay; + if (!allDay && startDate.Date != endDate.Date) + throw new LeaveUpdateValidationException("Partial-day leave must start and end on the same day"); + + var leaveTypeId = options.Type is null + ? existing.LeaveType?.Id + : await ResolveLeaveTypeAsync(options.Type, ct); + if (leaveTypeId is null) + throw new LeaveUpdateValidationException( + options.Type is null + ? "The existing leave does not contain a leave type" + : $"Unknown leave type: '{options.Type}'."); + + var note = options.Note?.Trim() ?? existing.Note?.Trim(); + if (string.IsNullOrWhiteSpace(note)) + throw new LeaveUpdateValidationException( + "A leave note is required. Provide --note because the existing leave has no note."); + + var approvedBy = options.ClearApprovedBy + ? null + : options.ApprovedBy?.Trim() ?? existing.ApprovedBy; + var optionalEmployees = options.ClearCc + ? [] + : options.Cc is not null + ? LeaveRequestParser.ParseOptionalEmployees(options.Cc) + : existing.OptionalEmp ?? []; + + var request = new UpdateLeaveRequest + { + Id = normalizedLeaveId, + RequestedEmpId = string.IsNullOrWhiteSpace(existing.RequestedEmpId) + ? employeeId + : existing.RequestedEmpId, + StartDate = startDate.ToString("o"), + EndDate = endDate.ToString("o"), + LeaveTypeId = leaveTypeId.Value, + Note = note, + UserStartTime = LeaveRequestParser.NormalizeTime( + options.StartTime ?? existing.UserStartTime ?? employeeSettings?.StartTime, + LeaveRequestParser.DefaultStartTime), + UserEndTime = LeaveRequestParser.NormalizeTime( + options.EndTime ?? existing.UserEndTime ?? employeeSettings?.EndTime, + LeaveRequestParser.DefaultEndTime), + AllDay = allDay, + OptionalEmp = optionalEmployees, + ApprovedBy = approvedBy, + TimeLessOverride = existing.TimeLessOverride + }; + + return new LeaveUpdatePlan(existing, request, changes); + } + + public Task ApplyAsync(LeaveUpdatePlan plan, CancellationToken ct = default) => + _api.UpdateLeaveAsync(plan.Request, ct); + + private async Task FindLeaveAsync( + string leaveId, + string employeeId, + CancellationToken ct) + { + foreach (var filter in new[] { "UPCOMING", "PAST" }) + { + var pageNumber = 1; + while (true) + { + var response = await _api.GetLeaveAsync( + filter, + pageNumber, + PageSize, + employeeId, + ct); + var page = response?.Leaves; + var match = page?.Items.FirstOrDefault(entry => + entry.Id.Equals(leaveId, StringComparison.OrdinalIgnoreCase)); + if (match is not null) + return match; + + if (page is null || pageNumber >= page.TotalPages) + break; + + pageNumber++; + } + } + + return null; + } + + private async Task ResolveLeaveTypeAsync(string typeInput, CancellationToken ct) + { + if (int.TryParse(typeInput, out var id)) + return id; + + var types = await _api.GetLeaveTypesAsync(ct); + return types.FirstOrDefault(type => + type.Name.Equals(typeInput, StringComparison.OrdinalIgnoreCase))?.Id; + } + + private static List DescribeChanges(LeaveUpdateOptions options) + { + var changes = new List(); + if (options.Start is not null) changes.Add($"Start -> {options.Start}"); + if (options.End is not null) changes.Add($"End -> {options.End}"); + if (options.Type is not null) changes.Add($"Type -> {options.Type}"); + if (options.Note is not null) changes.Add($"Note -> {Truncate(options.Note)}"); + if (options.ClearApprovedBy) changes.Add("Approver -> cleared"); + else if (options.ApprovedBy is not null) changes.Add($"Approver -> {options.ApprovedBy}"); + if (options.ClearCc) changes.Add("CC -> cleared"); + else if (options.Cc is not null) changes.Add($"CC -> {options.Cc}"); + if (options.AllDay is not null) changes.Add(options.AllDay.Value ? "Day mode -> full day" : "Day mode -> partial day"); + if (options.StartTime is not null) changes.Add($"Workday start -> {options.StartTime}"); + if (options.EndTime is not null) changes.Add($"Workday end -> {options.EndTime}"); + return changes; + } + + private static string? DatePart(string? value) => value?.Split('T')[0]; + + private static string Truncate(string value) => + value.Length > 50 ? value[..50] + "..." : value; + + private static bool IsWeekend(DateTimeOffset date) => + date.DayOfWeek is DayOfWeek.Saturday or DayOfWeek.Sunday; +} diff --git a/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs b/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs new file mode 100644 index 0000000..26fcf7b --- /dev/null +++ b/src/SSW.TimePro.Cli/Features/Leave/UpdateCommand.cs @@ -0,0 +1,193 @@ +using System.ComponentModel; +using SSW.TimePro.Cli.Infrastructure.ApiClient; +using SSW.TimePro.Cli.Infrastructure.Config; +using SSW.TimePro.Cli.Infrastructure.Output; +using Spectre.Console; +using Spectre.Console.Cli; + +namespace SSW.TimePro.Cli.Features.Leave; + +[Description("Update an existing leave request")] +public class UpdateCommand : AsyncCommand +{ + private readonly LeaveUpdateService _updateService; + private readonly IConfigService _config; + + public class Settings : CommandSettings + { + [CommandArgument(0, "")] + [Description("Leave entry ID")] + public string LeaveId { get; set; } = string.Empty; + + [CommandOption("--start ")] + [Description("New start date (yyyy-MM-dd)")] + public string? Start { get; set; } + + [CommandOption("--end ")] + [Description("New end date (yyyy-MM-dd)")] + public string? End { get; set; } + + [CommandOption("--type ")] + [Description("New leave type ID or active leave type name")] + public string? Type { get; set; } + + [CommandOption("--note ")] + [Description("New leave note/reason")] + public string? Note { get; set; } + + [CommandOption("--approved-by ")] + [Description("New approver email address")] + public string? ApprovedBy { get; set; } + + [CommandOption("--clear-approved-by")] + [Description("Remove the current approver")] + public bool ClearApprovedBy { get; set; } + + [CommandOption("--cc ")] + [Description("Replace CC recipients with a comma-separated email list")] + public string? Cc { get; set; } + + [CommandOption("--clear-cc")] + [Description("Remove all current CC recipients")] + public bool ClearCc { get; set; } + + [CommandOption("--half-day")] + [Description("Change the request to partial-day leave")] + public bool HalfDay { get; set; } + + [CommandOption("--full-day")] + [Description("Change the request to full-day leave")] + public bool FullDay { get; set; } + + [CommandOption("--start-time