crowdtime-cli 0.20.0__tar.gz → 0.21.1__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/PKG-INFO +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/pyproject.toml +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/clients_cmd.py +2 -2
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/expense_cmd.py +38 -7
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/invoice_cmd.py +5 -5
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/org_cmd.py +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/projects_cmd.py +3 -3
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/pto_cmd.py +4 -4
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/report_cmd.py +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/tasks_cmd.py +1 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/team_cmd.py +5 -5
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/timer_cmd.py +17 -3
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/timesheet_cmd.py +78 -14
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/formatters.py +4 -4
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/main.py +2 -1
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/skills/crowdtime/SKILL.md +10 -7
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/skills/crowdtime/references/commands.md +31 -10
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/skills/crowdtime/references/pto.md +3 -2
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +12 -5
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/.gitignore +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/LICENSE +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/README.md +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/models.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/version_check.py +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: crowdtime-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.21.1
|
|
4
4
|
Summary: AI-powered time tracking CLI — a modern, developer-friendly alternative to Harvest
|
|
5
5
|
Project-URL: Homepage, https://crowdtime.lat
|
|
6
6
|
Project-URL: Documentation, https://crowdtime.lat/docs
|
|
@@ -75,7 +75,7 @@ def list_clients(
|
|
|
75
75
|
return
|
|
76
76
|
|
|
77
77
|
table = Table(show_header=True, header_style="bold")
|
|
78
|
-
table.add_column("ID", style="dim")
|
|
78
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
79
79
|
table.add_column("Name")
|
|
80
80
|
table.add_column("Contact")
|
|
81
81
|
table.add_column("Projects", justify="right")
|
|
@@ -355,7 +355,7 @@ def list_contacts(
|
|
|
355
355
|
return
|
|
356
356
|
|
|
357
357
|
table = Table(show_header=True, header_style="bold")
|
|
358
|
-
table.add_column("ID", style="dim")
|
|
358
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
359
359
|
table.add_column("Name", width=20)
|
|
360
360
|
table.add_column("Email", width=25)
|
|
361
361
|
table.add_column("Phone", width=15)
|
|
@@ -21,16 +21,22 @@ console = Console()
|
|
|
21
21
|
# ─── Helpers ──────────────────────────────────────────────────────
|
|
22
22
|
|
|
23
23
|
|
|
24
|
-
def _print_expenses_table(expenses: list[dict]) -> None:
|
|
25
|
-
"""Print expenses in a Rich table."""
|
|
24
|
+
def _print_expenses_table(expenses: list[dict], me: str | None = None) -> None:
|
|
25
|
+
"""Print expenses in a Rich table, with a User column whenever one isn't yours."""
|
|
26
26
|
if not expenses:
|
|
27
27
|
console.print("[dim]No expenses found.[/dim]")
|
|
28
28
|
console.print("Run [bold]ct expense create[/bold] to log an expense.")
|
|
29
29
|
return
|
|
30
30
|
|
|
31
31
|
table = Table(show_header=True, header_style="bold")
|
|
32
|
-
|
|
32
|
+
# Full UUIDs: they are what show/edit/delete take, so never let Rich clip them.
|
|
33
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
33
34
|
table.add_column("Date", width=12)
|
|
35
|
+
# The list holds every expense the caller can see — colleagues' too for
|
|
36
|
+
# managers and project leads — so mark whose each one is (CRO-93).
|
|
37
|
+
show_user = any(str(exp.get("user")) != me for exp in expenses)
|
|
38
|
+
if show_user:
|
|
39
|
+
table.add_column("User", width=16)
|
|
34
40
|
table.add_column("Description", width=25)
|
|
35
41
|
table.add_column("Category", width=14)
|
|
36
42
|
table.add_column("Project", style="cyan", width=15)
|
|
@@ -51,9 +57,14 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
51
57
|
is_billable = exp.get("is_billable", False)
|
|
52
58
|
is_invoiced = exp.get("is_invoiced", False)
|
|
53
59
|
|
|
60
|
+
user_cell = (
|
|
61
|
+
["You" if str(exp.get("user")) == me else exp.get("user_name", "") or ""]
|
|
62
|
+
if show_user else []
|
|
63
|
+
)
|
|
54
64
|
table.add_row(
|
|
55
65
|
str(exp.get("id", "")),
|
|
56
66
|
exp.get("date", ""),
|
|
67
|
+
*user_cell,
|
|
57
68
|
exp.get("description", ""),
|
|
58
69
|
exp.get("category_name", exp.get("category", "")) or "",
|
|
59
70
|
exp.get("project_name", exp.get("project", "")) or "",
|
|
@@ -65,6 +76,8 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
65
76
|
|
|
66
77
|
console.print(table)
|
|
67
78
|
console.print(f"\n[bold]Total:[/bold] {format_currency(total)} ({len(expenses)} expenses)")
|
|
79
|
+
if show_user:
|
|
80
|
+
console.print("[dim]Includes other people's expenses. Use --mine for only yours.[/dim]")
|
|
68
81
|
|
|
69
82
|
|
|
70
83
|
# ─── Commands ─────────────────────────────────────────────────────
|
|
@@ -74,7 +87,12 @@ def _print_expenses_table(expenses: list[dict]) -> None:
|
|
|
74
87
|
def expense_default(ctx: typer.Context) -> None:
|
|
75
88
|
"""Manage expenses."""
|
|
76
89
|
if ctx.invoked_subcommand is None:
|
|
77
|
-
|
|
90
|
+
# Called directly, so every option needs a real value — Typer's
|
|
91
|
+
# defaults are OptionInfo objects outside the CLI parser.
|
|
92
|
+
list_expenses(
|
|
93
|
+
from_date=None, to_date=None, project=None, category=None,
|
|
94
|
+
billable=None, invoiced=None, mine=False, output_json=False,
|
|
95
|
+
)
|
|
78
96
|
|
|
79
97
|
|
|
80
98
|
@app.command("list")
|
|
@@ -85,22 +103,35 @@ def list_expenses(
|
|
|
85
103
|
category: Optional[str] = typer.Option(None, "--category", "-c", help="Filter by category."),
|
|
86
104
|
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable", help="Filter by billable status."),
|
|
87
105
|
invoiced: Optional[bool] = typer.Option(None, "--invoiced/--uninvoiced", help="Filter by invoiced status."),
|
|
106
|
+
mine: bool = typer.Option(
|
|
107
|
+
False, "--mine",
|
|
108
|
+
help="Only your own expenses. Managers and project leads otherwise see colleagues' too.",
|
|
109
|
+
),
|
|
88
110
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
89
111
|
) -> None:
|
|
90
112
|
"""List expenses with optional filters.
|
|
91
113
|
|
|
92
|
-
By default, shows the current month's expenses
|
|
114
|
+
By default, shows the current month's expenses — every expense you can
|
|
115
|
+
see. The table shows a User column whenever one isn't yours.
|
|
93
116
|
|
|
94
117
|
Examples:
|
|
95
118
|
ct expense list
|
|
96
119
|
ct expense list --from 2026-03-01 --to 2026-03-31
|
|
97
120
|
ct expense list --project myproject --billable
|
|
98
121
|
ct expense list --category travel --uninvoiced
|
|
122
|
+
ct expense list --mine
|
|
99
123
|
"""
|
|
100
124
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
101
125
|
|
|
102
126
|
params: dict = {}
|
|
103
127
|
|
|
128
|
+
me = client.current_user_id()
|
|
129
|
+
if mine:
|
|
130
|
+
if not me:
|
|
131
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
132
|
+
raise typer.Exit(1)
|
|
133
|
+
params["user"] = me
|
|
134
|
+
|
|
104
135
|
# Default to current month if no date filters provided
|
|
105
136
|
if not from_date and not to_date:
|
|
106
137
|
today = date_type.today()
|
|
@@ -137,7 +168,7 @@ def list_expenses(
|
|
|
137
168
|
if output_json:
|
|
138
169
|
print_json(results)
|
|
139
170
|
else:
|
|
140
|
-
_print_expenses_table(results)
|
|
171
|
+
_print_expenses_table(results, me)
|
|
141
172
|
except APIError as e:
|
|
142
173
|
format_error(e.message)
|
|
143
174
|
raise typer.Exit(1)
|
|
@@ -385,7 +416,7 @@ def list_categories(
|
|
|
385
416
|
return
|
|
386
417
|
|
|
387
418
|
table = Table(show_header=True, header_style="bold")
|
|
388
|
-
table.add_column("ID", style="dim")
|
|
419
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
389
420
|
table.add_column("Name", width=25)
|
|
390
421
|
|
|
391
422
|
for cat in results:
|
|
@@ -1305,7 +1305,7 @@ def list_recurring(
|
|
|
1305
1305
|
return
|
|
1306
1306
|
|
|
1307
1307
|
table = Table(show_header=True, header_style="bold")
|
|
1308
|
-
table.add_column("ID", style="dim")
|
|
1308
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1309
1309
|
table.add_column("Name", width=20)
|
|
1310
1310
|
table.add_column("Client", width=18)
|
|
1311
1311
|
table.add_column("Schedule", width=14)
|
|
@@ -1674,7 +1674,7 @@ def list_retainers(
|
|
|
1674
1674
|
return
|
|
1675
1675
|
|
|
1676
1676
|
table = Table(show_header=True, header_style="bold")
|
|
1677
|
-
table.add_column("ID", style="dim")
|
|
1677
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1678
1678
|
table.add_column("Name", width=20)
|
|
1679
1679
|
table.add_column("Client", width=18)
|
|
1680
1680
|
table.add_column("Monthly", justify="right", width=12)
|
|
@@ -1878,7 +1878,7 @@ def list_withdrawals(
|
|
|
1878
1878
|
return
|
|
1879
1879
|
|
|
1880
1880
|
table = Table(show_header=True, header_style="bold")
|
|
1881
|
-
table.add_column("ID", style="dim")
|
|
1881
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1882
1882
|
table.add_column("Invoice", width=14)
|
|
1883
1883
|
table.add_column("Amount", justify="right", width=12)
|
|
1884
1884
|
table.add_column("Date", width=12)
|
|
@@ -1981,7 +1981,7 @@ def tax_rates_list(
|
|
|
1981
1981
|
return
|
|
1982
1982
|
|
|
1983
1983
|
table = Table(title="Tax Rates")
|
|
1984
|
-
table.add_column("ID", style="dim")
|
|
1984
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
1985
1985
|
table.add_column("Name", style="bold")
|
|
1986
1986
|
table.add_column("Rate %", justify="right")
|
|
1987
1987
|
table.add_column("Default")
|
|
@@ -2140,7 +2140,7 @@ def uninvoiced_entries(
|
|
|
2140
2140
|
table.add_column("Entries", justify="right")
|
|
2141
2141
|
table.add_column("Hours", justify="right")
|
|
2142
2142
|
table.add_column("Amount", justify="right", style="green")
|
|
2143
|
-
table.add_column("Client ID", style="dim")
|
|
2143
|
+
table.add_column("Client ID", style="dim", no_wrap=True, min_width=36)
|
|
2144
2144
|
|
|
2145
2145
|
total_entries = 0
|
|
2146
2146
|
total_hours = Decimal("0")
|
|
@@ -383,7 +383,7 @@ def invitations(
|
|
|
383
383
|
return
|
|
384
384
|
|
|
385
385
|
table = Table(show_header=True, header_style="bold")
|
|
386
|
-
table.add_column("ID", style="dim")
|
|
386
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
387
387
|
table.add_column("Email", width=25)
|
|
388
388
|
table.add_column("Role", width=18)
|
|
389
389
|
table.add_column("Status", width=10)
|
|
@@ -618,7 +618,7 @@ def list_members(
|
|
|
618
618
|
table.add_column("Project Role", width=16)
|
|
619
619
|
table.add_column("Org Role", width=16)
|
|
620
620
|
table.add_column("Rate", justify="right", width=10)
|
|
621
|
-
table.add_column("User ID", style="dim")
|
|
621
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
622
622
|
|
|
623
623
|
for m in members_list:
|
|
624
624
|
proj_role = m.get("project_role") or "[dim]-[/dim]"
|
|
@@ -677,7 +677,7 @@ def list_tasks(
|
|
|
677
677
|
table.add_column("Task Rate", justify="right", width=12)
|
|
678
678
|
table.add_column("Project Rate", justify="right", width=14)
|
|
679
679
|
table.add_column("Active", width=8)
|
|
680
|
-
table.add_column("Task ID", style="dim")
|
|
680
|
+
table.add_column("Task ID", style="dim", no_wrap=True, min_width=36)
|
|
681
681
|
|
|
682
682
|
for t in tasks_list:
|
|
683
683
|
billable = "[green]Yes[/green]" if t.get("task_is_billable") else "[dim]No[/dim]"
|
|
@@ -784,7 +784,7 @@ def assignable_users(
|
|
|
784
784
|
return
|
|
785
785
|
|
|
786
786
|
table = Table(title="Assignable users")
|
|
787
|
-
table.add_column("ID", style="dim")
|
|
787
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
788
788
|
table.add_column("Name")
|
|
789
789
|
table.add_column("Email")
|
|
790
790
|
table.add_column("Org role")
|
|
@@ -55,7 +55,7 @@ console = Console()
|
|
|
55
55
|
|
|
56
56
|
|
|
57
57
|
# Valid enum values mirrored from the contract.
|
|
58
|
-
_VALID_KINDS = {"vacation", "sick", "holiday", "personal", "other"}
|
|
58
|
+
_VALID_KINDS = {"vacation", "sick", "holiday", "personal", "birthday", "other"}
|
|
59
59
|
_VALID_STATUSES = {"pending", "approved", "rejected", "canceled"}
|
|
60
60
|
|
|
61
61
|
|
|
@@ -211,7 +211,7 @@ def request_cmd(
|
|
|
211
211
|
...,
|
|
212
212
|
help="Date or range: '2026-06-01', '2026-06-01:2026-06-05', 'today', 'next-monday'.",
|
|
213
213
|
),
|
|
214
|
-
kind: str = typer.Option("vacation", "--kind", "-k", help="vacation|sick|holiday|personal|other."),
|
|
214
|
+
kind: str = typer.Option("vacation", "--kind", "-k", help="vacation|sick|holiday|personal|birthday|other. Birthday: one day, once per calendar year."),
|
|
215
215
|
hours_per_day: Optional[str] = typer.Option(
|
|
216
216
|
None, "--hours-per-day",
|
|
217
217
|
help="Decimal hours. Empty/omitted = full day from membership capacity.",
|
|
@@ -256,7 +256,7 @@ def add_cmd(
|
|
|
256
256
|
...,
|
|
257
257
|
help="Date or range: '2026-06-01', '2026-06-01:2026-06-05', 'today', 'next-monday'.",
|
|
258
258
|
),
|
|
259
|
-
kind: str = typer.Option("vacation", "--kind", "-k", help="vacation|sick|holiday|personal|other."),
|
|
259
|
+
kind: str = typer.Option("vacation", "--kind", "-k", help="vacation|sick|holiday|personal|birthday|other. Birthday: one day, once per calendar year."),
|
|
260
260
|
hours_per_day: Optional[str] = typer.Option(
|
|
261
261
|
None, "--hours-per-day",
|
|
262
262
|
help="Decimal hours per day. Omit for full-day from membership.",
|
|
@@ -778,7 +778,7 @@ def allowance_list_cmd(
|
|
|
778
778
|
from rich.table import Table
|
|
779
779
|
|
|
780
780
|
table = Table(show_header=True, header_style="bold")
|
|
781
|
-
table.add_column("User ID", style="dim")
|
|
781
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
782
782
|
table.add_column("Name")
|
|
783
783
|
table.add_column("Days/year", justify="right")
|
|
784
784
|
table.add_column("Counting from")
|
|
@@ -562,7 +562,7 @@ def saved_list(
|
|
|
562
562
|
return
|
|
563
563
|
|
|
564
564
|
table = Table(show_header=True, header_style="bold", title="Saved Reports")
|
|
565
|
-
table.add_column("ID",
|
|
565
|
+
table.add_column("ID", no_wrap=True, min_width=36)
|
|
566
566
|
table.add_column("Name", style="bold", width=25)
|
|
567
567
|
table.add_column("Type", width=18)
|
|
568
568
|
table.add_column("Shared", justify="center", width=8)
|
|
@@ -195,7 +195,7 @@ def list_tasks(
|
|
|
195
195
|
return
|
|
196
196
|
|
|
197
197
|
table = Table(show_header=True, header_style="bold")
|
|
198
|
-
table.add_column("ID", style="dim")
|
|
198
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
199
199
|
table.add_column("Name", width=25)
|
|
200
200
|
table.add_column("Project", style="cyan", width=15)
|
|
201
201
|
table.add_column("Billable", justify="center", width=8)
|
|
@@ -343,7 +343,7 @@ def list_assignments(
|
|
|
343
343
|
return
|
|
344
344
|
|
|
345
345
|
table = Table(show_header=True, header_style="bold")
|
|
346
|
-
table.add_column("Assignment ID", style="dim")
|
|
346
|
+
table.add_column("Assignment ID", style="dim", no_wrap=True, min_width=36)
|
|
347
347
|
table.add_column("Manager")
|
|
348
348
|
table.add_column("Manager Email", style="dim")
|
|
349
349
|
table.add_column("Direct Report")
|
|
@@ -402,8 +402,8 @@ def reports(
|
|
|
402
402
|
table = Table(show_header=True, header_style="bold")
|
|
403
403
|
table.add_column("Direct Report")
|
|
404
404
|
table.add_column("Email", style="dim")
|
|
405
|
-
table.add_column("User ID", style="dim")
|
|
406
|
-
table.add_column("Assignment ID", style="dim")
|
|
405
|
+
table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
406
|
+
table.add_column("Assignment ID", style="dim", no_wrap=True, min_width=36)
|
|
407
407
|
|
|
408
408
|
for row in rows:
|
|
409
409
|
table.add_row(
|
|
@@ -496,7 +496,7 @@ def show(
|
|
|
496
496
|
projects = profile.get("projects") or []
|
|
497
497
|
if projects:
|
|
498
498
|
table = Table(show_header=True, header_style="bold", title="Assigned projects")
|
|
499
|
-
table.add_column("Project ID", style="dim")
|
|
499
|
+
table.add_column("Project ID", style="dim", no_wrap=True, min_width=36)
|
|
500
500
|
table.add_column("Project")
|
|
501
501
|
table.add_column("Client", style="dim")
|
|
502
502
|
table.add_column("Role on project")
|
|
@@ -532,7 +532,7 @@ def show(
|
|
|
532
532
|
by_project = overview.get("by_project") or []
|
|
533
533
|
if by_project:
|
|
534
534
|
table = Table(show_header=True, header_style="bold", title="By project")
|
|
535
|
-
table.add_column("Project ID", style="dim")
|
|
535
|
+
table.add_column("Project ID", style="dim", no_wrap=True, min_width=36)
|
|
536
536
|
table.add_column("Project")
|
|
537
537
|
table.add_column("Client", style="dim")
|
|
538
538
|
table.add_column("Hours", justify="right")
|
|
@@ -281,11 +281,25 @@ def continue_timer(
|
|
|
281
281
|
"""
|
|
282
282
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
283
283
|
|
|
284
|
-
#
|
|
284
|
+
# The time list holds everything the caller can see — colleagues' entries
|
|
285
|
+
# too for project leads and managers — so ask for your own (CRO-93).
|
|
286
|
+
me = client.current_user_id()
|
|
287
|
+
if not me:
|
|
288
|
+
format_error("Could not resolve your user id. Run ct auth login again.")
|
|
289
|
+
raise typer.Exit(1)
|
|
290
|
+
|
|
291
|
+
# Fetch recent entries to find the last non-running one. Server ordering is
|
|
292
|
+
# newest date first; future-dated entries and paid-leave rows aren't
|
|
293
|
+
# something you'd resume.
|
|
285
294
|
try:
|
|
286
|
-
|
|
295
|
+
from datetime import date as date_type
|
|
296
|
+
data = client.get("/time/", params={
|
|
297
|
+
"user": me,
|
|
298
|
+
"date_to": date_type.today().isoformat(),
|
|
299
|
+
"page_size": "10",
|
|
300
|
+
})
|
|
287
301
|
from ..formatters import extract_results
|
|
288
|
-
entries = extract_results(data)
|
|
302
|
+
entries = [e for e in extract_results(data) if e.get("source") != "time_off"]
|
|
289
303
|
if not entries:
|
|
290
304
|
format_error("No recent entries to continue from.")
|
|
291
305
|
raise typer.Exit(1)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Timesheet commands: list, submit, approve, reject, recall, reopen, team overview."""
|
|
1
|
+
"""Timesheet commands: list, submit, remind, approve, reject, recall, reopen, team overview."""
|
|
2
2
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
@@ -41,7 +41,7 @@ def _print_timesheets_table(timesheets: list[dict], show_user: bool = False) ->
|
|
|
41
41
|
return
|
|
42
42
|
|
|
43
43
|
table = Table(show_header=True, header_style="bold")
|
|
44
|
-
table.add_column("ID", style="dim")
|
|
44
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
45
45
|
if show_user:
|
|
46
46
|
table.add_column("User", width=20)
|
|
47
47
|
table.add_column("Period", width=25)
|
|
@@ -246,17 +246,27 @@ def submit_timesheet(
|
|
|
246
246
|
period_end: str = typer.Option(
|
|
247
247
|
..., "--to", help="Period end date (YYYY-MM-DD or keyword)."
|
|
248
248
|
),
|
|
249
|
+
user: Optional[str] = typer.Option(
|
|
250
|
+
None, "--user", "-u",
|
|
251
|
+
help="Submit on someone's behalf (email or UUID). Reviewers only.",
|
|
252
|
+
),
|
|
249
253
|
force: bool = typer.Option(False, "--force", "-f", help="Skip confirmation prompt."),
|
|
250
254
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
251
255
|
) -> None:
|
|
252
256
|
"""Submit a timesheet for approval.
|
|
253
257
|
|
|
254
|
-
Computes total hours from
|
|
258
|
+
Computes total hours from the time entries in the given period.
|
|
255
259
|
Shows a preview of hours before submitting (use --force to skip).
|
|
256
260
|
|
|
261
|
+
--user submits a week someone logged but never sent. It needs the same
|
|
262
|
+
authority as approving their whole timesheet (admin, their manager, or
|
|
263
|
+
running every project they logged on); the submission is then reviewed
|
|
264
|
+
as usual.
|
|
265
|
+
|
|
257
266
|
Examples:
|
|
258
267
|
ct timesheet submit --from 2026-03-09 --to 2026-03-15
|
|
259
268
|
ct timesheet submit --from monday --to friday --force
|
|
269
|
+
ct timesheet submit --from 2026-03-09 --to 2026-03-15 --user ana@acme.com
|
|
260
270
|
"""
|
|
261
271
|
try:
|
|
262
272
|
start = format_date(parse_date(period_start))
|
|
@@ -267,13 +277,23 @@ def submit_timesheet(
|
|
|
267
277
|
|
|
268
278
|
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
269
279
|
|
|
280
|
+
user_id = None
|
|
281
|
+
if user:
|
|
282
|
+
from .pto_cmd import _resolve_user_to_uuid
|
|
283
|
+
|
|
284
|
+
try:
|
|
285
|
+
user_id = _resolve_user_to_uuid(client, user)
|
|
286
|
+
except APIError as e:
|
|
287
|
+
format_error(e.message)
|
|
288
|
+
raise typer.Exit(1)
|
|
289
|
+
|
|
270
290
|
# Preview: fetch entries for the period and show summary before confirming
|
|
271
291
|
if not force and not output_json:
|
|
272
292
|
try:
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
"
|
|
276
|
-
|
|
293
|
+
entry_params = {"date_from": start, "date_to": end}
|
|
294
|
+
if user_id:
|
|
295
|
+
entry_params["user"] = user_id
|
|
296
|
+
entries_data = client.get("/time-entries/", params=entry_params)
|
|
277
297
|
entries = extract_results(entries_data)
|
|
278
298
|
total_h = sum(Decimal(str(e.get("hours", 0))) for e in entries)
|
|
279
299
|
billable_h = sum(
|
|
@@ -296,10 +316,10 @@ def submit_timesheet(
|
|
|
296
316
|
pass # If preview fails, proceed to submit (server will validate)
|
|
297
317
|
|
|
298
318
|
try:
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
"
|
|
302
|
-
|
|
319
|
+
payload = {"period_start": start, "period_end": end}
|
|
320
|
+
if user_id:
|
|
321
|
+
payload["user_id"] = user_id
|
|
322
|
+
result = client.post("/timesheets/submit/", data=payload)
|
|
303
323
|
|
|
304
324
|
if output_json:
|
|
305
325
|
print_json(result)
|
|
@@ -316,6 +336,50 @@ def submit_timesheet(
|
|
|
316
336
|
raise typer.Exit(1)
|
|
317
337
|
|
|
318
338
|
|
|
339
|
+
@app.command("remind")
|
|
340
|
+
def remind_timesheet(
|
|
341
|
+
user: str = typer.Option(..., "--user", "-u", help="Who to remind (email or UUID)."),
|
|
342
|
+
period_start: str = typer.Option(
|
|
343
|
+
..., "--from", help="Period start date (YYYY-MM-DD or keyword)."
|
|
344
|
+
),
|
|
345
|
+
period_end: str = typer.Option(
|
|
346
|
+
..., "--to", help="Period end date (YYYY-MM-DD or keyword)."
|
|
347
|
+
),
|
|
348
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
349
|
+
) -> None:
|
|
350
|
+
"""Email someone a reminder to submit their timesheet (project_manager+).
|
|
351
|
+
|
|
352
|
+
For a week they haven't submitted. One reminder per person and period an hour.
|
|
353
|
+
|
|
354
|
+
Examples:
|
|
355
|
+
ct timesheet remind --user ana@acme.com --from 2026-03-09 --to 2026-03-15
|
|
356
|
+
"""
|
|
357
|
+
from .pto_cmd import _resolve_user_to_uuid
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
start = format_date(parse_date(period_start))
|
|
361
|
+
end = format_date(parse_date(period_end))
|
|
362
|
+
except ValueError as e:
|
|
363
|
+
format_error(str(e))
|
|
364
|
+
raise typer.Exit(1)
|
|
365
|
+
|
|
366
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
367
|
+
try:
|
|
368
|
+
user_id = _resolve_user_to_uuid(client, user)
|
|
369
|
+
result = client.post("/timesheets/remind/", data={
|
|
370
|
+
"user_id": user_id,
|
|
371
|
+
"period_start": start,
|
|
372
|
+
"period_end": end,
|
|
373
|
+
})
|
|
374
|
+
if output_json:
|
|
375
|
+
print_json(result)
|
|
376
|
+
else:
|
|
377
|
+
format_success(f"Reminder sent to {user} for {start} → {end}.")
|
|
378
|
+
except APIError as e:
|
|
379
|
+
format_error(e.message)
|
|
380
|
+
raise typer.Exit(1)
|
|
381
|
+
|
|
382
|
+
|
|
319
383
|
@app.command("approve")
|
|
320
384
|
def approve_timesheet(
|
|
321
385
|
timesheet_id: str = typer.Argument(..., help="Timesheet ID to approve."),
|
|
@@ -620,7 +684,7 @@ def viewable_users(
|
|
|
620
684
|
return
|
|
621
685
|
|
|
622
686
|
table = Table(show_header=True, header_style="bold")
|
|
623
|
-
table.add_column("ID", style="dim")
|
|
687
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
624
688
|
table.add_column("Name", width=20)
|
|
625
689
|
table.add_column("Email", width=30)
|
|
626
690
|
|
|
@@ -1057,7 +1121,7 @@ def compliance_report(
|
|
|
1057
1121
|
title=f"[blue]Pending Approval ({len(pending)})[/blue]",
|
|
1058
1122
|
)
|
|
1059
1123
|
table.add_column("Member", width=22)
|
|
1060
|
-
table.add_column("Timesheet ID", style="dim")
|
|
1124
|
+
table.add_column("Timesheet ID", style="dim", no_wrap=True, min_width=36)
|
|
1061
1125
|
table.add_column("Hours", justify="right", width=8)
|
|
1062
1126
|
table.add_column("Submitted", width=12)
|
|
1063
1127
|
table.add_column("Days Pending", justify="right", width=13)
|
|
@@ -1083,7 +1147,7 @@ def compliance_report(
|
|
|
1083
1147
|
title=f"[red]Rejected ({len(rejected_list)})[/red]",
|
|
1084
1148
|
)
|
|
1085
1149
|
table.add_column("Member", width=22)
|
|
1086
|
-
table.add_column("Timesheet ID", style="dim")
|
|
1150
|
+
table.add_column("Timesheet ID", style="dim", no_wrap=True, min_width=36)
|
|
1087
1151
|
table.add_column("Hours", justify="right", width=8)
|
|
1088
1152
|
table.add_column("Reviewer Notes", width=35)
|
|
1089
1153
|
|
|
@@ -208,7 +208,7 @@ def format_entries_table(
|
|
|
208
208
|
return
|
|
209
209
|
|
|
210
210
|
table = Table(show_header=True, header_style="bold")
|
|
211
|
-
table.add_column("ID", style="dim")
|
|
211
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
212
212
|
if show_date:
|
|
213
213
|
table.add_column("Date", width=12)
|
|
214
214
|
if show_user:
|
|
@@ -352,7 +352,7 @@ def format_projects_table(projects: list[Project]) -> None:
|
|
|
352
352
|
return
|
|
353
353
|
|
|
354
354
|
table = Table(show_header=True, header_style="bold")
|
|
355
|
-
table.add_column("ID", style="dim")
|
|
355
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
356
356
|
table.add_column("Name", style="bold", width=20)
|
|
357
357
|
table.add_column("Code", width=8)
|
|
358
358
|
table.add_column("Client", width=15)
|
|
@@ -438,7 +438,7 @@ def format_favorites_table(favorites: list[FavoriteEntry]) -> None:
|
|
|
438
438
|
return
|
|
439
439
|
|
|
440
440
|
table = Table(show_header=True, header_style="bold")
|
|
441
|
-
table.add_column("ID", style="dim")
|
|
441
|
+
table.add_column("ID", style="dim", no_wrap=True, min_width=36)
|
|
442
442
|
table.add_column("Project", style="cyan", width=15)
|
|
443
443
|
table.add_column("Task", width=15)
|
|
444
444
|
table.add_column("Description", width=30)
|
|
@@ -952,7 +952,7 @@ def format_timeoff_summary_panel(summary: TimeOffSummary) -> Panel:
|
|
|
952
952
|
padding=(0, 2),
|
|
953
953
|
)
|
|
954
954
|
by_user_table.add_column("User", width=24)
|
|
955
|
-
by_user_table.add_column("User ID", style="dim",
|
|
955
|
+
by_user_table.add_column("User ID", style="dim", no_wrap=True, min_width=36)
|
|
956
956
|
by_user_table.add_column("Hours", justify="right", width=10)
|
|
957
957
|
by_user_table.add_column("Days", justify="right", width=6)
|
|
958
958
|
if summary.by_user:
|
|
@@ -428,13 +428,14 @@ def expense_list_alias(
|
|
|
428
428
|
category: Optional[str] = typer.Option(None, "--category", "-c"),
|
|
429
429
|
billable: Optional[bool] = typer.Option(None, "--billable/--no-billable"),
|
|
430
430
|
invoiced: Optional[bool] = typer.Option(None, "--invoiced/--uninvoiced"),
|
|
431
|
+
mine: bool = typer.Option(False, "--mine"),
|
|
431
432
|
output_json: bool = typer.Option(False, "--json"),
|
|
432
433
|
) -> None:
|
|
433
434
|
"""Alias for 'expense list'."""
|
|
434
435
|
expense_cmd.list_expenses(
|
|
435
436
|
from_date=from_date, to_date=to_date, project=project,
|
|
436
437
|
category=category, billable=billable, invoiced=invoiced,
|
|
437
|
-
output_json=output_json,
|
|
438
|
+
mine=mine, output_json=output_json,
|
|
438
439
|
)
|
|
439
440
|
|
|
440
441
|
|
|
@@ -140,7 +140,8 @@ ct clients remove-contact <id> <contact-id>
|
|
|
140
140
|
|
|
141
141
|
### Expenses
|
|
142
142
|
```bash
|
|
143
|
-
ct expense list # List expenses
|
|
143
|
+
ct expense list # List expenses you can see (colleagues' too if you're a manager or run a project)
|
|
144
|
+
ct expense list --mine # Only your own — use this for "my expenses" and totals
|
|
144
145
|
ct expense list --from 2026-03-01 --to 2026-03-31 --billable
|
|
145
146
|
ct expense create --amount 49.99 "Domain renewal" --project acme-website --category "Software" --vendor "Namecheap" --billable
|
|
146
147
|
ct expense edit <id> --amount 59.99 --description "Domain + SSL renewal"
|
|
@@ -148,9 +149,8 @@ ct expense delete <id>
|
|
|
148
149
|
ct expense categories # List expense categories
|
|
149
150
|
ct expense add-category "Travel" # Create a new category
|
|
150
151
|
|
|
151
|
-
# Short alias
|
|
152
|
-
ct ex
|
|
153
|
-
ct ex create --amount 25 "Lunch with client" --category "Meals" --billable
|
|
152
|
+
# Short alias (list only — takes the same options as expense list)
|
|
153
|
+
ct ex --mine # expense list --mine
|
|
154
154
|
```
|
|
155
155
|
|
|
156
156
|
**Who sees which expenses**: members and viewers see only their own; project managers also see expenses on projects they manage; account managers and above see all. Only the owner of an expense (or an account manager+) can edit or delete it, and not once it has been invoiced. `--project` must be a project the user is assigned to. The invoiced flag is set by invoicing and cannot be edited.
|
|
@@ -296,10 +296,13 @@ A favorite can only point at a project the user is assigned to (and a task on th
|
|
|
296
296
|
|
|
297
297
|
### Timesheets
|
|
298
298
|
```bash
|
|
299
|
-
ct timesheet list
|
|
299
|
+
ct timesheet list --mine # List your own timesheets
|
|
300
|
+
ct timesheet list # Everyone's you can see (a User column marks rows that aren't yours)
|
|
300
301
|
ct timesheet list --status submitted # Filter by status
|
|
301
302
|
ct timesheet submit --from 2026-03-10 --to 2026-03-16 # Submit full week
|
|
302
303
|
ct timesheet submit --from 2026-03-30 --to 2026-03-31 # Partial week (end-of-month split)
|
|
304
|
+
ct timesheet submit --from 2026-03-10 --to 2026-03-16 --user ana@acme.com # Submit a week someone logged but never sent (same authority as whole approve)
|
|
305
|
+
ct timesheet remind --user ana@acme.com --from 2026-03-10 --to 2026-03-16 # Email them a reminder to submit (project_manager+)
|
|
303
306
|
ct timesheet recall <id> # Recall submitted timesheet back to draft (--force to skip confirm)
|
|
304
307
|
ct timesheet approve <id> # Approve the whole timesheet (admin, direct manager, or PM of every project in it)
|
|
305
308
|
ct timesheet approve <id> --project <project-id> # Approve one project's portion (PM for that project)
|
|
@@ -424,7 +427,7 @@ ct log 0.25 --project argo --task BE --notes "Daily standup"
|
|
|
424
427
|
|
|
425
428
|
### Time Off (PTO)
|
|
426
429
|
|
|
427
|
-
Per-user time-off entries with a kind (vacation, sick, holiday, personal, other) and a status lifecycle (pending → approved/rejected; pending → canceled). Members submit requests that await approval, and their approvers are emailed automatically; project managers and above can also add PTO directly on behalf of someone in their scope (auto-approved).
|
|
430
|
+
Per-user time-off entries with a kind (vacation, sick, holiday, personal, birthday, other) and a status lifecycle (pending → approved/rejected; pending → canceled). Members submit requests that await approval, and their approvers are emailed automatically; project managers and above can also add PTO directly on behalf of someone in their scope (auto-approved).
|
|
428
431
|
|
|
429
432
|
> **IMPORTANT**: Never use magic routing (`ct "..."`) or `ct ai parse` for PTO operations. Always construct explicit `ct pto ...` commands.
|
|
430
433
|
> Full reference: `references/pto.md` and `references/commands.md` § PTO.
|
|
@@ -435,7 +438,7 @@ ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved
|
|
|
435
438
|
ct pto list [--mine|--team] # List PTO entries with optional filters
|
|
436
439
|
ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
|
|
437
440
|
ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day (your own: pending only)
|
|
438
|
-
ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+
|
|
441
|
+
ct pto cancel <id> [--force] # Cancel a PTO entry (own pending; or project manager+ someone else's; your own decided leave needs another approver, admins excepted)
|
|
439
442
|
ct pto approve <id> [--notes "..."] # Approve a pending entry (project manager+; never your own unless admin)
|
|
440
443
|
ct pto reject <id> --notes "..." # Reject a pending entry — notes required (project manager+; never your own unless admin)
|
|
441
444
|
ct pto calendar [--from --to --user --status] # PTO slice for a date window (approved only unless --status)
|
|
@@ -233,9 +233,9 @@ ct timer continue [--note/-n NOTE] [--billable/-b] [--no-billable/-B] [--json]
|
|
|
233
233
|
| `--no-billable`, `-B` | flag | Override billable status (mark non-billable) |
|
|
234
234
|
| `--json` | flag | Output as JSON |
|
|
235
235
|
|
|
236
|
-
Starts a new timer with the same project, task, and description as
|
|
236
|
+
Starts a new timer with the same project, task, and description as your most recent entry (up to today; paid-leave rows are skipped). Great for resuming after a break. Use `--billable`/`--no-billable` to override the original entry's billable status.
|
|
237
237
|
|
|
238
|
-
- **Endpoint:** `GET /time
|
|
238
|
+
- **Endpoint:** `GET /time/?user=<you>` (fetch your last entry) + `POST /time/start/`
|
|
239
239
|
- **Alias:** `ct c` (e.g. `ct c -b` to continue as billable)
|
|
240
240
|
|
|
241
241
|
### ct timer discard
|
|
@@ -563,10 +563,12 @@ ct expense list [options]
|
|
|
563
563
|
| `--category` | string | Filter by category |
|
|
564
564
|
| `--billable` | flag | Only billable expenses |
|
|
565
565
|
| `--invoiced` | flag | Only invoiced expenses |
|
|
566
|
+
| `--mine` | flag | Only your own expenses |
|
|
566
567
|
| `--json` | flag | JSON output |
|
|
567
568
|
|
|
568
|
-
Shows: ID, Date, Description, Amount, Category, Project, Billable, Invoiced.
|
|
569
|
-
Scope: members and viewers see their own expenses; project
|
|
569
|
+
Shows: ID, Date, User (only when a row isn't yours), Description, Amount, Category, Project, Billable, Invoiced. The total adds up every row shown.
|
|
570
|
+
Scope: members and viewers see their own expenses; anyone running a project also sees those logged on it; account managers and above see all. Pass `--mine` whenever the question is about the user's own expenses — without it, a manager's or project lead's list and total include colleagues'. In `--json` output compare each row's `user` with yours.
|
|
571
|
+
Alias: `ct ex [options]` (list only).
|
|
570
572
|
Endpoint: `GET /expenses/`
|
|
571
573
|
|
|
572
574
|
### ct expense show
|
|
@@ -602,8 +604,8 @@ ct expense create --amount AMOUNT DESCRIPTION [options]
|
|
|
602
604
|
**Usage examples:**
|
|
603
605
|
```bash
|
|
604
606
|
ct expense create --amount 49.99 "Domain renewal" --project acme-website --category "Software" --billable
|
|
605
|
-
ct
|
|
606
|
-
ct
|
|
607
|
+
ct expense create --amount 125 "Client lunch" --category "Meals" --vendor "La Trattoria" --billable --notes "Q1 planning lunch"
|
|
608
|
+
ct expense create --amount 29.99 "Adobe subscription" -p design-project --category "Software" -d yesterday -b
|
|
607
609
|
```
|
|
608
610
|
|
|
609
611
|
Endpoint: `POST /expenses/`
|
|
@@ -1735,13 +1737,14 @@ Lists your timesheets, plus — for managers and project managers — the timesh
|
|
|
1735
1737
|
### ct timesheet submit
|
|
1736
1738
|
|
|
1737
1739
|
```
|
|
1738
|
-
ct timesheet submit --from DATE --to DATE [--force/-f] [--json]
|
|
1740
|
+
ct timesheet submit --from DATE --to DATE [--user/-u USER] [--force/-f] [--json]
|
|
1739
1741
|
```
|
|
1740
1742
|
|
|
1741
1743
|
| Option | Type | Description |
|
|
1742
1744
|
|--------|------|-------------|
|
|
1743
1745
|
| `--from` | string (required) | Period start date |
|
|
1744
1746
|
| `--to` | string (required) | Period end date |
|
|
1747
|
+
| `--user`, `-u` | string | Submit on someone's behalf (email or UUID) |
|
|
1745
1748
|
| `--force`, `-f` | flag | Skip confirmation prompt |
|
|
1746
1749
|
| `--json` | flag | JSON output |
|
|
1747
1750
|
|
|
@@ -1749,6 +1752,23 @@ Submits a timesheet for the given period (any date range — not limited to full
|
|
|
1749
1752
|
|
|
1750
1753
|
A zero-hour period is rejected **unless every working day in it is covered by approved time off** — a full vacation week has no time entries by design and is submitted as-is. If someone was away and the submit is rejected, the fix is an approved PTO entry (`ct pto request`), not a fake time entry. Endpoint: `POST /timesheets/submit/`
|
|
1751
1754
|
|
|
1755
|
+
`--user` submits a week someone logged but never sent (body `user_id`). It needs the same authority as approving their whole timesheet — admin/owner, their direct manager, or running every project they logged on that period — otherwise `403`. The submission is recorded as theirs (the audit event names who sent it) and is then approved or rejected as usual. To nudge them to do it themselves, use `ct timesheet remind`.
|
|
1756
|
+
|
|
1757
|
+
### ct timesheet remind
|
|
1758
|
+
|
|
1759
|
+
```
|
|
1760
|
+
ct timesheet remind --user USER --from DATE --to DATE [--json]
|
|
1761
|
+
```
|
|
1762
|
+
|
|
1763
|
+
| Option | Type | Description |
|
|
1764
|
+
|--------|------|-------------|
|
|
1765
|
+
| `--user`, `-u` | string (required) | Who to remind (email or UUID) |
|
|
1766
|
+
| `--from` | string (required) | Period start date |
|
|
1767
|
+
| `--to` | string (required) | Period end date |
|
|
1768
|
+
| `--json` | flag | JSON output |
|
|
1769
|
+
|
|
1770
|
+
Emails the person a reminder to submit their timesheet for the period, naming who asked (project_manager+, anyone on your roster — `ct timesheet viewable-users`). Refused with `400` when a timesheet for the period is already submitted or approved, and with `429` when that person was reminded about the same period less than an hour ago. Endpoint: `POST /timesheets/remind/` → `202`.
|
|
1771
|
+
|
|
1752
1772
|
### ct timesheet approve
|
|
1753
1773
|
|
|
1754
1774
|
```
|
|
@@ -2740,7 +2760,7 @@ Submit a PTO request for yourself. Created with `status=pending` — must be app
|
|
|
2740
2760
|
| Argument/Option | Type | Required | Description |
|
|
2741
2761
|
|-----------------|------|----------|-------------|
|
|
2742
2762
|
| `<dates>` | string | yes | `YYYY-MM-DD` or `YYYY-MM-DD:YYYY-MM-DD` |
|
|
2743
|
-
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
|
|
2763
|
+
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `birthday` (one day, once per calendar year), `other` |
|
|
2744
2764
|
| `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default (see `references/pto.md`) |
|
|
2745
2765
|
| `--notes`, `-n` | string | no | Reason or notes |
|
|
2746
2766
|
| `--json` | flag | no | JSON output |
|
|
@@ -2786,7 +2806,7 @@ Add a PTO entry on behalf of another user (project manager and above). The entry
|
|
|
2786
2806
|
|-----------------|------|----------|-------------|
|
|
2787
2807
|
| `<user>` | string | yes | User email or UUID |
|
|
2788
2808
|
| `<dates>` | string | yes | `YYYY-MM-DD` or `YYYY-MM-DD:YYYY-MM-DD` |
|
|
2789
|
-
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
|
|
2809
|
+
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `birthday` (one day, once per calendar year), `other` |
|
|
2790
2810
|
| `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default |
|
|
2791
2811
|
| `--notes`, `-n` | string | no | Reason or context notes |
|
|
2792
2812
|
| `--json` | flag | no | JSON output |
|
|
@@ -2842,7 +2862,7 @@ routing from the list payload.
|
|
|
2842
2862
|
ct pto list [--mine] [--team] [--status/-s STATUS] [--kind/-k KIND] [--from DATE] [--to DATE] [--user/-u USER] [--json]
|
|
2843
2863
|
```
|
|
2844
2864
|
|
|
2845
|
-
List PTO entries.
|
|
2865
|
+
List PTO entries. Shows your own entries by default (same as `--mine`); `--team` shows every entry you can see.
|
|
2846
2866
|
|
|
2847
2867
|
**Who can call it:**
|
|
2848
2868
|
- Any member — sees own entries only (scoped by server regardless of flags).
|
|
@@ -2970,6 +2990,7 @@ Cancel a PTO entry. Transitions status to `canceled`.
|
|
|
2970
2990
|
**Authorization:**
|
|
2971
2991
|
- Own entry + `status=pending` → any member.
|
|
2972
2992
|
- Manager+ → any non-canceled entry for users in their visibility scope (including approved entries).
|
|
2993
|
+
- Your own entry once it is approved or rejected → admins and owners only; a manager or project manager gets a `403` and must ask another approver (same rule as editing).
|
|
2973
2994
|
|
|
2974
2995
|
`decided_by` / `decided_at` are NOT stamped by cancel (only by approve/reject).
|
|
2975
2996
|
|
{crowdtime_cli-0.20.0 → crowdtime_cli-0.21.1}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
@@ -25,6 +25,7 @@ A `TimeOffEntry` represents a block of time off for a single user within an orga
|
|
|
25
25
|
| `sick` | Sick Leave | Illness, medical appointment, recovery. |
|
|
26
26
|
| `holiday` | Public Holiday | National or regional public holiday observed by the org. |
|
|
27
27
|
| `personal` | Personal Day | Personal errands, family obligations, other non-vacation personal time. |
|
|
28
|
+
| `birthday` | Birthday | The person's birthday day off. **One day, once per calendar year** — a second one in the same year, or a multi-day range, is rejected. |
|
|
28
29
|
| `other` | Other | Anything that doesn't fit the above categories. |
|
|
29
30
|
|
|
30
31
|
If the user doesn't specify a kind, default to `vacation` but **confirm before submitting**.
|
|
@@ -44,7 +45,7 @@ If the user doesn't specify a kind, default to `vacation` but **confirm before s
|
|
|
44
45
|
| Status | Meaning | Who can act next |
|
|
45
46
|
|--------|---------|-----------------|
|
|
46
47
|
| `pending` | Submitted, awaiting decision | Manager+ can approve or reject; owner of the entry can cancel or edit |
|
|
47
|
-
| `approved` | Approved and active | Manager+ can cancel (removes from active capacity)
|
|
48
|
+
| `approved` | Approved and active | Manager+ can cancel (removes from active capacity) or edit dates/hours (audit-logged) — but not their own entry unless they are admin/owner |
|
|
48
49
|
| `rejected` | Rejected by manager | Entry owner can submit a new request with corrected dates |
|
|
49
50
|
| `canceled` | Canceled (no longer active) | Terminal state — no further transitions |
|
|
50
51
|
|
|
@@ -92,7 +93,7 @@ Project managers and account managers using `ct pto list --team` or `ct pto add
|
|
|
92
93
|
| `ct pto add <user>` (on-behalf) | No | Within scope → approved (400 outside it) | Any user → approved |
|
|
93
94
|
| `ct pto list / show` | Own only | Within scope | Any |
|
|
94
95
|
| `ct pto edit` | Own + pending only | Within scope (any status); **their own** entry only while pending | Any |
|
|
95
|
-
| `ct pto cancel` | Own + pending only | Within scope (any non-canceled) | Any |
|
|
96
|
+
| `ct pto cancel` | Own + pending only | Within scope (any non-canceled); **their own** entry only while pending | Any |
|
|
96
97
|
| `ct pto approve` | No | Within scope + pending, **never their own** (403) | Any pending, including their own |
|
|
97
98
|
| `ct pto reject` | No | Within scope + pending, **never their own** (403) | Any pending, including their own |
|
|
98
99
|
| `ct pto calendar / summary` | Own | Within scope | Any |
|
|
@@ -577,8 +577,8 @@ ct timesheet submit --from 2026-03-09 --to 2026-03-15 --force
|
|
|
577
577
|
ct timesheet submit --from monday --to wednesday --force # Close out March
|
|
578
578
|
ct timesheet submit --from thursday --to friday --force # Start April
|
|
579
579
|
|
|
580
|
-
# Check submission status
|
|
581
|
-
ct timesheet list
|
|
580
|
+
# Check submission status (--mine: project leads and managers also see colleagues' timesheets)
|
|
581
|
+
ct timesheet list --mine
|
|
582
582
|
|
|
583
583
|
# Made a mistake? Recall it back to draft (before approval) — confirms before executing
|
|
584
584
|
ct timesheet recall <timesheet-id> --notes "Need to fix Wednesday entries"
|
|
@@ -634,6 +634,11 @@ ct timesheet reject <timesheet-id> --notes "Missing entries for Wednesday — pl
|
|
|
634
634
|
# Reject multiple timesheets at once
|
|
635
635
|
ct timesheet bulk-reject <id1> <id2> --notes "Incomplete entries for the week"
|
|
636
636
|
|
|
637
|
+
# Someone logged the week but never submitted ("No timesheet" in `ct timesheet team`)?
|
|
638
|
+
# There's nothing to reopen — nudge them, or submit it for them and then approve it
|
|
639
|
+
ct timesheet remind --user ana@acme.com --from 2026-03-09 --to 2026-03-15
|
|
640
|
+
ct timesheet submit --from 2026-03-09 --to 2026-03-15 --user ana@acme.com --force
|
|
641
|
+
|
|
637
642
|
# Approved it too early? Reopen — back to draft, entries unlocked, user fixes & resubmits
|
|
638
643
|
ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
|
|
639
644
|
ct timesheet reopen <timesheet-id> --notes "Wrong project on Tuesday" --force
|
|
@@ -709,7 +714,7 @@ ct expense create --amount 49.99 "Domain renewal" --project acme-website --categ
|
|
|
709
714
|
ct expense create --amount 29.99 "Team Slack subscription" --category "Software"
|
|
710
715
|
|
|
711
716
|
# Log a past expense
|
|
712
|
-
ct
|
|
717
|
+
ct expense create --amount 85 "Client dinner" --category "Meals" --vendor "La Trattoria" -d yesterday --billable -p acme-project
|
|
713
718
|
```
|
|
714
719
|
|
|
715
720
|
### Setting Up Expense Categories
|
|
@@ -729,8 +734,10 @@ ct expense add-category "Hardware"
|
|
|
729
734
|
### Reviewing Expenses
|
|
730
735
|
|
|
731
736
|
```bash
|
|
732
|
-
# List expenses for a period
|
|
737
|
+
# List expenses for a period. Managers and project leads also see colleagues'
|
|
738
|
+
# expenses here; add --mine when the user means their own
|
|
733
739
|
ct expense list --from 2026-03-01 --to 2026-03-31
|
|
740
|
+
ct expense list --mine --from 2026-03-01 --to 2026-03-31
|
|
734
741
|
|
|
735
742
|
# Filter by project and billable status
|
|
736
743
|
ct expense list --project acme-website --billable
|
|
@@ -1477,7 +1484,7 @@ Use this when a member wants to request time off. The request goes into `pending
|
|
|
1477
1484
|
|
|
1478
1485
|
**Before running any command, ask the user:**
|
|
1479
1486
|
1. What dates? (single day or range) — if they say "next week" or "a few days in June", ASK for the exact dates. Never assume.
|
|
1480
|
-
2. What kind? (vacation / sick / holiday / personal / other) — if not specified, default to `vacation` and confirm.
|
|
1487
|
+
2. What kind? (vacation / sick / holiday / personal / birthday / other) — if not specified, default to `vacation` and confirm. Birthday is a single day, once per calendar year.
|
|
1481
1488
|
3. Hours per day? — leave empty unless they want partial days (e.g. half-day sick leave).
|
|
1482
1489
|
4. Notes? — optional but useful for the manager.
|
|
1483
1490
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|