Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
18 changes: 16 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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

Expand Down
30 changes: 25 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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":<int|null>,"message":"...","detail":<string|null>}}` (all keys always present) — and exits non-zero. Human-readable error/warning text always goes to **stderr**, so it never corrupts the JSON stream.

Expand Down Expand Up @@ -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 <ID> --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 <ID> --start 2026-04-01 --end 2026-04-01 \
--note "Updated travel date" --dry-run --json

# Cancel a leave request
tp leave cancel <ID> --reason "Plans changed" --yes
```
Expand All @@ -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:
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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)

Expand Down Expand Up @@ -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
Expand Down
8 changes: 8 additions & 0 deletions release-notes/0.2.6.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
# 0.2.6

- Added `tp leave update <ID>` 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.
2 changes: 1 addition & 1 deletion release-notes/latest.md
165 changes: 60 additions & 105 deletions src/SSW.TimePro.Cli/Features/Leave/CreateCommand.cs
Original file line number Diff line number Diff line change
@@ -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;

Expand All @@ -10,7 +9,7 @@ namespace SSW.TimePro.Cli.Features.Leave;
[Description("Create a leave request")]
public class CreateCommand : AsyncCommand<CreateCommand.Settings>
{
private readonly ITimeProApiClient _api;
private readonly LeaveCreateService _createService;
private readonly ITenantProvider _tenantProvider;

public class Settings : CommandSettings
Expand Down Expand Up @@ -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<int> 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 <id>' first.");
WriteValidationError(
settings.Json,
"No active tenant or employee ID configured. Run 'tp login --tenant <id>' 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 });
Expand All @@ -181,6 +126,11 @@ protected override async Task<int> 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.
Expand All @@ -193,24 +143,29 @@ protected override async Task<int> 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<int?> 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;
}
}
Loading
Loading