crowdtime-cli 0.19.0__tar.gz → 0.21.0__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.19.0 → crowdtime_cli-0.21.0}/PKG-INFO +1 -1
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/pyproject.toml +1 -1
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/__init__.py +1 -1
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/pto_cmd.py +125 -16
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/team_cmd.py +176 -3
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/timesheet_cmd.py +74 -10
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/formatters.py +35 -4
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/models.py +7 -7
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/skills/crowdtime/SKILL.md +9 -3
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/skills/crowdtime/references/commands.md +45 -7
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md +1 -19
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/skills/crowdtime/references/workflows.md +9 -1
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/.gitignore +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/LICENSE +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/README.md +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/auth.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/client.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/__init__.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/ai_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/auth_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/billing_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/calendar_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/clients_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/config_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/expense_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/favorites_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/insights_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/invoice_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/log_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/org_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/payroll_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/projects_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/report_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/skill_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/tasks_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/timer_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/commands/version_cmd.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/config.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/main.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/oauth.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/resolvers.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/utils.py +0 -0
- {crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/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.0
|
|
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
|
|
@@ -46,12 +46,16 @@ from ..models import (
|
|
|
46
46
|
)
|
|
47
47
|
from ..utils import format_date, format_duration, parse_date, parse_date_range
|
|
48
48
|
|
|
49
|
-
app = typer.Typer(name="pto", help="Manage
|
|
49
|
+
app = typer.Typer(name="pto", help="Manage time off requests.")
|
|
50
|
+
# Hidden from help: nothing may tell someone without paid time off that it
|
|
51
|
+
# exists (CRO-84). Admins and people who have it know where to look.
|
|
52
|
+
allowance_app = typer.Typer(name="allowance", help="Paid time off allowances (admin+).")
|
|
53
|
+
app.add_typer(allowance_app, name="allowance", hidden=True)
|
|
50
54
|
console = Console()
|
|
51
55
|
|
|
52
56
|
|
|
53
57
|
# Valid enum values mirrored from the contract.
|
|
54
|
-
_VALID_KINDS = {"vacation", "sick", "holiday", "personal", "other"}
|
|
58
|
+
_VALID_KINDS = {"vacation", "sick", "holiday", "personal", "birthday", "other"}
|
|
55
59
|
_VALID_STATUSES = {"pending", "approved", "rejected", "canceled"}
|
|
56
60
|
|
|
57
61
|
|
|
@@ -207,7 +211,7 @@ def request_cmd(
|
|
|
207
211
|
...,
|
|
208
212
|
help="Date or range: '2026-06-01', '2026-06-01:2026-06-05', 'today', 'next-monday'.",
|
|
209
213
|
),
|
|
210
|
-
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."),
|
|
211
215
|
hours_per_day: Optional[str] = typer.Option(
|
|
212
216
|
None, "--hours-per-day",
|
|
213
217
|
help="Decimal hours. Empty/omitted = full day from membership capacity.",
|
|
@@ -252,18 +256,12 @@ def add_cmd(
|
|
|
252
256
|
...,
|
|
253
257
|
help="Date or range: '2026-06-01', '2026-06-01:2026-06-05', 'today', 'next-monday'.",
|
|
254
258
|
),
|
|
255
|
-
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."),
|
|
256
260
|
hours_per_day: Optional[str] = typer.Option(
|
|
257
261
|
None, "--hours-per-day",
|
|
258
262
|
help="Decimal hours per day. Omit for full-day from membership.",
|
|
259
263
|
),
|
|
260
264
|
notes: Optional[str] = typer.Option(None, "--notes", "-n", help="Optional free-text notes."),
|
|
261
|
-
paid: Optional[bool] = typer.Option(
|
|
262
|
-
None, "--paid/--unpaid",
|
|
263
|
-
help="Whether these hours are paid. Omit to follow the organization's "
|
|
264
|
-
"policy for this kind. Account managers and above only — the API "
|
|
265
|
-
"rejects the request from anyone below, it is not ignored.",
|
|
266
|
-
),
|
|
267
265
|
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
268
266
|
) -> None:
|
|
269
267
|
"""On-behalf PTO entry, auto-approved. Project manager+, within their scope."""
|
|
@@ -289,18 +287,13 @@ def add_cmd(
|
|
|
289
287
|
payload["hours_per_day"] = str(hpd)
|
|
290
288
|
if notes is not None:
|
|
291
289
|
payload["notes"] = notes
|
|
292
|
-
# Declared since the flag was added but never sent, so --unpaid silently
|
|
293
|
-
# booked paid leave. The backend defaults every kind to paid, so the
|
|
294
|
-
# omission was not neutral.
|
|
295
|
-
if paid is not None:
|
|
296
|
-
payload["is_paid"] = paid
|
|
297
290
|
|
|
298
291
|
try:
|
|
299
292
|
data = client.post("/time-off/", data=payload)
|
|
300
293
|
except APIError as e:
|
|
301
294
|
# Surface the rejection as a friendly error — the backend refuses
|
|
302
295
|
# `user` values outside the caller's visible set, refuses it entirely
|
|
303
|
-
# below project manager
|
|
296
|
+
# below project manager.
|
|
304
297
|
format_error(e.message)
|
|
305
298
|
raise typer.Exit(1)
|
|
306
299
|
|
|
@@ -721,3 +714,119 @@ def summary_cmd(
|
|
|
721
714
|
|
|
722
715
|
summary = TimeOffSummary(**data)
|
|
723
716
|
console.print(format_timeoff_summary_panel(summary))
|
|
717
|
+
|
|
718
|
+
|
|
719
|
+
# ── Paid time off (CRO-84) ──────────────────────────────────────────────
|
|
720
|
+
# Whether someone is paid for leave follows the allowance an admin gave them;
|
|
721
|
+
# nobody flags a request as paid. The API answers 404 to anyone who may not
|
|
722
|
+
# know, so these commands print the same thing for "none" and "not allowed".
|
|
723
|
+
|
|
724
|
+
|
|
725
|
+
def _print_balance(data: dict) -> None:
|
|
726
|
+
b = data.get("balance")
|
|
727
|
+
name = data.get("name") or data.get("email")
|
|
728
|
+
days = data.get("paid_time_off_days_per_year")
|
|
729
|
+
if not b:
|
|
730
|
+
console.print(f"[bold]{name}[/bold]: no paid time off — their leave is unpaid.")
|
|
731
|
+
return
|
|
732
|
+
console.print(
|
|
733
|
+
f"[bold]{name}[/bold]: {b['remaining_days']} of {b['allowance_days']} paid days left "
|
|
734
|
+
f"({b['year_start']} → {b['year_end']})"
|
|
735
|
+
)
|
|
736
|
+
console.print(
|
|
737
|
+
f" used {b['used_days']} · requested {b['pending_days']} · unpaid {b['unpaid_days']}"
|
|
738
|
+
f" [dim]{days} days/year from {data.get('paid_time_off_start_date')}[/dim]"
|
|
739
|
+
)
|
|
740
|
+
|
|
741
|
+
|
|
742
|
+
@app.command("balance", hidden=True)
|
|
743
|
+
def balance_cmd(
|
|
744
|
+
user: Optional[str] = typer.Option(None, "--user", "-u", help="Someone whose leave you approve (email or UUID). Default: you."),
|
|
745
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
746
|
+
) -> None:
|
|
747
|
+
"""Your paid time off balance, or that of someone whose leave you approve."""
|
|
748
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
749
|
+
try:
|
|
750
|
+
user_id = _resolve_user_to_uuid(client, user) if user else client.current_user_id()
|
|
751
|
+
data = client.get(f"/members/{user_id}/paid-time-off/")
|
|
752
|
+
except APIError as e:
|
|
753
|
+
if e.status_code == 404:
|
|
754
|
+
console.print("[dim]No balance to show.[/dim]")
|
|
755
|
+
return
|
|
756
|
+
format_error(e.message)
|
|
757
|
+
raise typer.Exit(1)
|
|
758
|
+
if output_json:
|
|
759
|
+
print_json(data)
|
|
760
|
+
return
|
|
761
|
+
_print_balance(data)
|
|
762
|
+
|
|
763
|
+
|
|
764
|
+
@allowance_app.command("list")
|
|
765
|
+
def allowance_list_cmd(
|
|
766
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
767
|
+
) -> None:
|
|
768
|
+
"""Every member's paid time off allowance and remaining days. Admin+."""
|
|
769
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
770
|
+
try:
|
|
771
|
+
rows = client.get("/members/paid-time-off/").get("results", [])
|
|
772
|
+
except APIError as e:
|
|
773
|
+
format_error("Only admins can list allowances." if e.status_code == 404 else e.message)
|
|
774
|
+
raise typer.Exit(1)
|
|
775
|
+
if output_json:
|
|
776
|
+
print_json(rows)
|
|
777
|
+
return
|
|
778
|
+
from rich.table import Table
|
|
779
|
+
|
|
780
|
+
table = Table(show_header=True, header_style="bold")
|
|
781
|
+
table.add_column("User ID", style="dim")
|
|
782
|
+
table.add_column("Name")
|
|
783
|
+
table.add_column("Days/year", justify="right")
|
|
784
|
+
table.add_column("Counting from")
|
|
785
|
+
table.add_column("Left this year", justify="right")
|
|
786
|
+
for r in rows:
|
|
787
|
+
b = r.get("balance")
|
|
788
|
+
table.add_row(
|
|
789
|
+
r["user_id"], r.get("name") or r.get("email"),
|
|
790
|
+
str(r["paid_time_off_days_per_year"]) if r.get("paid_time_off_days_per_year") else "—",
|
|
791
|
+
str(r.get("paid_time_off_start_date") or "—"),
|
|
792
|
+
str(b["remaining_days"]) if b else "—",
|
|
793
|
+
)
|
|
794
|
+
console.print(table)
|
|
795
|
+
|
|
796
|
+
|
|
797
|
+
@allowance_app.command("set")
|
|
798
|
+
def allowance_set_cmd(
|
|
799
|
+
user: str = typer.Argument(..., help="Member (email or UUID)."),
|
|
800
|
+
days: str = typer.Option(..., "--days", help="Paid days per allowance year; 0 for none."),
|
|
801
|
+
start: Optional[str] = typer.Option(
|
|
802
|
+
None, "--from",
|
|
803
|
+
help="When the allowance starts counting (YYYY-MM-DD): the day they joined, or the day "
|
|
804
|
+
"this arrangement started. Defaults to the day they joined.",
|
|
805
|
+
),
|
|
806
|
+
output_json: bool = typer.Option(False, "--json", help="Output as JSON."),
|
|
807
|
+
) -> None:
|
|
808
|
+
"""Set someone's paid time off. Admin+, never your own.
|
|
809
|
+
|
|
810
|
+
Applies to leave approved from now on; leave already approved keeps what it was paid.
|
|
811
|
+
"""
|
|
812
|
+
try:
|
|
813
|
+
value = Decimal(days)
|
|
814
|
+
except InvalidOperation:
|
|
815
|
+
format_error(f"--days must be a number, got '{days}'.")
|
|
816
|
+
raise typer.Exit(1)
|
|
817
|
+
payload: dict = {"paid_time_off_days_per_year": str(value)}
|
|
818
|
+
if start:
|
|
819
|
+
payload["paid_time_off_start_date"] = format_date(_parse_date_or_exit(start))
|
|
820
|
+
|
|
821
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
822
|
+
try:
|
|
823
|
+
user_id = _resolve_user_to_uuid(client, user)
|
|
824
|
+
data = client.patch(f"/members/{user_id}/paid-time-off/", data=payload)
|
|
825
|
+
except APIError as e:
|
|
826
|
+
format_error("Member not found." if e.status_code == 404 else e.message)
|
|
827
|
+
raise typer.Exit(1)
|
|
828
|
+
if output_json:
|
|
829
|
+
print_json(data)
|
|
830
|
+
return
|
|
831
|
+
format_success("Paid time off updated.")
|
|
832
|
+
_print_balance(data)
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
"""Team commands:
|
|
1
|
+
"""Team commands: member profiles and manager → direct-report assignments.
|
|
2
2
|
|
|
3
3
|
Mirrors Harvest's manager model. Admins assign members as direct reports
|
|
4
4
|
to a manager; the manager can see those members' hours across any project
|
|
@@ -11,6 +11,7 @@ concurrent edits from clobbering each other.
|
|
|
11
11
|
|
|
12
12
|
from __future__ import annotations
|
|
13
13
|
|
|
14
|
+
import re
|
|
14
15
|
from typing import List, Optional
|
|
15
16
|
|
|
16
17
|
import typer
|
|
@@ -18,9 +19,9 @@ from rich.console import Console
|
|
|
18
19
|
from rich.table import Table
|
|
19
20
|
|
|
20
21
|
from ..client import APIError, CrowdTimeClient
|
|
21
|
-
from ..formatters import extract_results, format_error, format_success, print_json
|
|
22
|
+
from ..formatters import extract_results, format_currency, format_error, format_success, print_json
|
|
22
23
|
|
|
23
|
-
app = typer.Typer(name="team", help="
|
|
24
|
+
app = typer.Typer(name="team", help="Member profiles and direct-report assignments.")
|
|
24
25
|
console = Console()
|
|
25
26
|
|
|
26
27
|
|
|
@@ -413,3 +414,175 @@ def reports(
|
|
|
413
414
|
)
|
|
414
415
|
|
|
415
416
|
console.print(table)
|
|
417
|
+
|
|
418
|
+
|
|
419
|
+
_UUID_RE = re.compile(r"^[0-9a-fA-F-]{36}$")
|
|
420
|
+
|
|
421
|
+
|
|
422
|
+
def _resolve_user_id(client: CrowdTimeClient, ref: str) -> str:
|
|
423
|
+
"""User id for ``me``, a user UUID, or an email.
|
|
424
|
+
|
|
425
|
+
Emails go through the member list, which project managers and up can read
|
|
426
|
+
(each within their reach) — everyone else passes ``me`` or the user ID.
|
|
427
|
+
"""
|
|
428
|
+
if ref.lower() == "me":
|
|
429
|
+
user_id = client.current_user_id()
|
|
430
|
+
if not user_id:
|
|
431
|
+
raise APIError("Could not determine the current user.", status_code=401)
|
|
432
|
+
return user_id
|
|
433
|
+
if _UUID_RE.match(ref):
|
|
434
|
+
return ref
|
|
435
|
+
try:
|
|
436
|
+
return _resolve_membership(client, ref)["user"]["id"]
|
|
437
|
+
except APIError as e:
|
|
438
|
+
if e.status_code == 403:
|
|
439
|
+
raise APIError(
|
|
440
|
+
"Looking members up by email needs project manager access. Use 'me' or the user ID.",
|
|
441
|
+
status_code=403,
|
|
442
|
+
)
|
|
443
|
+
raise
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
def _hours(value) -> str:
|
|
447
|
+
return f"{float(value or 0):.2f}h"
|
|
448
|
+
|
|
449
|
+
|
|
450
|
+
@app.command("show")
|
|
451
|
+
def show(
|
|
452
|
+
member: str = typer.Argument(..., help="Member: 'me', user ID, or email (email lookup needs project_manager+)."),
|
|
453
|
+
date_from: Optional[str] = typer.Option(None, "--from", help="Start date YYYY-MM-DD (default: 30 days ago)."),
|
|
454
|
+
date_to: Optional[str] = typer.Option(None, "--to", help="End date YYYY-MM-DD (default: today)."),
|
|
455
|
+
output_json: bool = typer.Option(False, "--json", help="Output profile and overview as JSON."),
|
|
456
|
+
) -> None:
|
|
457
|
+
"""Show a member's profile and activity, as far as your role allows.
|
|
458
|
+
|
|
459
|
+
Account managers and up can open anyone; others can open themselves and
|
|
460
|
+
the people on projects they run. Sections you may not see are left out:
|
|
461
|
+
project managers see only time on their projects, utilization and
|
|
462
|
+
timesheets are for the person's managers, rates are manager+.
|
|
463
|
+
|
|
464
|
+
Examples:
|
|
465
|
+
ct team show me
|
|
466
|
+
ct team show alice@acme.com --from 2026-09-01 --to 2026-09-30
|
|
467
|
+
"""
|
|
468
|
+
client = CrowdTimeClient(require_auth=True, require_org=True)
|
|
469
|
+
params = {k: v for k, v in (("from", date_from), ("to", date_to)) if v}
|
|
470
|
+
try:
|
|
471
|
+
user_id = _resolve_user_id(client, member)
|
|
472
|
+
profile = client.get(f"/members/{user_id}/")
|
|
473
|
+
overview = client.get(f"/members/{user_id}/overview/", params=params)
|
|
474
|
+
except APIError as e:
|
|
475
|
+
format_error(e.message)
|
|
476
|
+
raise typer.Exit(1)
|
|
477
|
+
|
|
478
|
+
if output_json:
|
|
479
|
+
print_json({"profile": profile, "overview": overview})
|
|
480
|
+
return
|
|
481
|
+
|
|
482
|
+
user = profile.get("user", {})
|
|
483
|
+
access = profile.get("access", {})
|
|
484
|
+
console.print(f"[bold]{user.get('full_name') or user.get('email')}[/bold] [dim]{profile.get('role')}[/dim]")
|
|
485
|
+
console.print(f"[dim]User ID:[/dim] {user.get('id')}")
|
|
486
|
+
console.print(f"[dim]Email:[/dim] {user.get('email')} [dim]Timezone:[/dim] {user.get('timezone')}")
|
|
487
|
+
line = f"[dim]Capacity:[/dim] {profile.get('weekly_capacity_hours')}h/week [dim]Joined:[/dim] {str(profile.get('joined_at', ''))[:10]}"
|
|
488
|
+
if access.get("money") and profile.get("hourly_rate") is not None:
|
|
489
|
+
line += f" [dim]Rate:[/dim] {format_currency(profile['hourly_rate'])}/hr"
|
|
490
|
+
console.print(line)
|
|
491
|
+
for label, key in (("Reports to", "managers"), ("Direct reports", "direct_reports")):
|
|
492
|
+
people = profile.get(key) or []
|
|
493
|
+
if people:
|
|
494
|
+
console.print(f"[dim]{label}:[/dim] " + ", ".join(p["name"] for p in people))
|
|
495
|
+
|
|
496
|
+
projects = profile.get("projects") or []
|
|
497
|
+
if projects:
|
|
498
|
+
table = Table(show_header=True, header_style="bold", title="Assigned projects")
|
|
499
|
+
table.add_column("Project ID", style="dim")
|
|
500
|
+
table.add_column("Project")
|
|
501
|
+
table.add_column("Client", style="dim")
|
|
502
|
+
table.add_column("Role on project")
|
|
503
|
+
for p in projects:
|
|
504
|
+
role = (p.get("effective_role") or p.get("project_role") or "-").replace("_", " ")
|
|
505
|
+
table.add_row(p["id"], p["name"], p.get("client_name") or "-", role)
|
|
506
|
+
console.print(table)
|
|
507
|
+
|
|
508
|
+
timer = overview.get("running_timer")
|
|
509
|
+
if timer:
|
|
510
|
+
task = f" / {timer['task_name']}" if timer.get("task_name") else ""
|
|
511
|
+
console.print(f"[green]Tracking now:[/green] {timer['project_name']}{task} (since {timer['started_at']})")
|
|
512
|
+
|
|
513
|
+
if access.get("level") == "profile":
|
|
514
|
+
console.print("\n[dim]Profile only — time details are for this person's managers and project managers.[/dim]")
|
|
515
|
+
else:
|
|
516
|
+
if access.get("level") == "projects":
|
|
517
|
+
console.print("\n[dim]Showing time on the projects you run.[/dim]")
|
|
518
|
+
summary = overview.get("summary") or {}
|
|
519
|
+
console.print(f"\n[bold]{overview.get('from')} → {overview.get('to')}[/bold]")
|
|
520
|
+
stats = [
|
|
521
|
+
f"Hours {_hours(summary.get('total_hours'))}",
|
|
522
|
+
f"Billable {summary.get('billable_percent', 0)}%",
|
|
523
|
+
f"Entries {summary.get('entry_count', 0)}",
|
|
524
|
+
f"Days logged {summary.get('days_logged', 0)}",
|
|
525
|
+
]
|
|
526
|
+
if "utilization_percent" in summary and summary["utilization_percent"] is not None:
|
|
527
|
+
stats.append(f"Utilization {summary['utilization_percent']}% of {summary.get('capacity_hours')}h")
|
|
528
|
+
if access.get("money") and summary.get("billable_amount") is not None:
|
|
529
|
+
stats.append(f"Billable amount {format_currency(summary['billable_amount'])}")
|
|
530
|
+
console.print(" ".join(stats))
|
|
531
|
+
|
|
532
|
+
by_project = overview.get("by_project") or []
|
|
533
|
+
if by_project:
|
|
534
|
+
table = Table(show_header=True, header_style="bold", title="By project")
|
|
535
|
+
table.add_column("Project ID", style="dim")
|
|
536
|
+
table.add_column("Project")
|
|
537
|
+
table.add_column("Client", style="dim")
|
|
538
|
+
table.add_column("Hours", justify="right")
|
|
539
|
+
table.add_column("Billable", justify="right")
|
|
540
|
+
if access.get("money"):
|
|
541
|
+
table.add_column("Amount", justify="right")
|
|
542
|
+
for p in by_project:
|
|
543
|
+
row = [p["project_id"], p["project_name"], p.get("client_name") or "-",
|
|
544
|
+
_hours(p["hours"]), _hours(p["billable_hours"])]
|
|
545
|
+
if access.get("money"):
|
|
546
|
+
row.append(format_currency(p.get("billable_amount")))
|
|
547
|
+
table.add_row(*row)
|
|
548
|
+
console.print(table)
|
|
549
|
+
|
|
550
|
+
recent = overview.get("recent_entries") or []
|
|
551
|
+
if recent:
|
|
552
|
+
table = Table(show_header=True, header_style="bold", title="Recent entries")
|
|
553
|
+
table.add_column("Date")
|
|
554
|
+
table.add_column("Project")
|
|
555
|
+
table.add_column("Task", style="dim")
|
|
556
|
+
table.add_column("Hours", justify="right")
|
|
557
|
+
table.add_column("Notes", style="dim")
|
|
558
|
+
for e in recent:
|
|
559
|
+
table.add_row(e["date"], e["project_name"], e.get("task_name") or "-", _hours(e["hours"]), e.get("notes") or "")
|
|
560
|
+
console.print(table)
|
|
561
|
+
|
|
562
|
+
timesheets = overview.get("timesheets")
|
|
563
|
+
if timesheets:
|
|
564
|
+
table = Table(show_header=True, header_style="bold", title="Timesheets")
|
|
565
|
+
table.add_column("Period")
|
|
566
|
+
table.add_column("Status")
|
|
567
|
+
table.add_column("Hours", justify="right")
|
|
568
|
+
for p in timesheets["periods"]:
|
|
569
|
+
hours = _hours(p["total_hours"]) if p.get("total_hours") is not None else "-"
|
|
570
|
+
table.add_row(f"{p['period_start']} → {p['period_end']}", p["status"], hours)
|
|
571
|
+
console.print(table)
|
|
572
|
+
if timesheets.get("submitted_rate") is not None:
|
|
573
|
+
console.print(f"[dim]Submitted on {timesheets['submitted_rate']}% of recent weeks, {timesheets.get('missing_count', 0)} missing.[/dim]")
|
|
574
|
+
|
|
575
|
+
time_off = overview.get("time_off")
|
|
576
|
+
if time_off:
|
|
577
|
+
kinds = ", ".join(f"{k} {v}d" for k, v in time_off.get("days_by_kind", {}).items()) or "none"
|
|
578
|
+
console.print(f"\n[bold]Time off {time_off.get('year')}:[/bold] {time_off.get('total_days', 0)} approved days ({kinds})")
|
|
579
|
+
bal = time_off.get("balance") # only present for those allowed to see paid leave
|
|
580
|
+
if bal:
|
|
581
|
+
console.print(
|
|
582
|
+
f" paid time off: {bal['remaining_days']} of {bal['allowance_days']} days left "
|
|
583
|
+
f"({bal['year_start']} → {bal['year_end']}), {bal['unpaid_days']} unpaid"
|
|
584
|
+
)
|
|
585
|
+
elif "balance" in time_off:
|
|
586
|
+
console.print(" no paid time off: their leave is unpaid")
|
|
587
|
+
for row in time_off.get("upcoming", []):
|
|
588
|
+
console.print(f" upcoming: {row['start_date']} → {row['end_date']} {row['kind']} [{row['status']}]")
|
|
@@ -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
|
|
|
@@ -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."),
|
|
@@ -710,6 +710,9 @@ def format_timeoff_detail(entry: TimeOffEntry) -> Panel:
|
|
|
710
710
|
|
|
711
711
|
table.add_row("Kind", _pto_kind_label(entry))
|
|
712
712
|
table.add_row("Status", _pto_status_label(entry))
|
|
713
|
+
pay = _pto_pay_label(entry) # empty unless the API sent the split (CRO-84)
|
|
714
|
+
if pay:
|
|
715
|
+
table.add_row("Paid", pay)
|
|
713
716
|
table.add_row("Notes", entry.notes or "[dim](none)[/dim]")
|
|
714
717
|
|
|
715
718
|
table.add_row("", "") # spacer
|
|
@@ -729,7 +732,7 @@ def format_timeoff_detail(entry: TimeOffEntry) -> Panel:
|
|
|
729
732
|
table.add_row("Updated", format_iso_datetime(str(entry.updated_at)))
|
|
730
733
|
|
|
731
734
|
border = _pto_status_style(entry.status)
|
|
732
|
-
title = f"
|
|
735
|
+
title = f"Time off {entry.id}"
|
|
733
736
|
return Panel(table, title=title, border_style=border, padding=(0, 1))
|
|
734
737
|
|
|
735
738
|
|
|
@@ -740,7 +743,7 @@ def format_timeoff_table(
|
|
|
740
743
|
) -> Table:
|
|
741
744
|
"""Tabular view. Returns the Rich Table for caller-side printing."""
|
|
742
745
|
table = Table(show_header=True, header_style="bold", title=None)
|
|
743
|
-
table.add_column("ID", style="dim",
|
|
746
|
+
table.add_column("ID", style="dim", min_width=36, no_wrap=True) # full UUID, never truncated
|
|
744
747
|
if show_user:
|
|
745
748
|
table.add_column("User", width=20)
|
|
746
749
|
table.add_column("Dates", width=25)
|
|
@@ -748,7 +751,15 @@ def format_timeoff_table(
|
|
|
748
751
|
table.add_column("Hours", justify="right", width=8)
|
|
749
752
|
table.add_column("Kind", width=10)
|
|
750
753
|
table.add_column("Status", width=10)
|
|
751
|
-
|
|
754
|
+
# Paid/unpaid split, only when the API sent it — it never does to someone
|
|
755
|
+
# who isn't allowed to know paid leave exists (CRO-84).
|
|
756
|
+
show_pay = any(
|
|
757
|
+
getattr(e, "paid_days", None) is not None or getattr(e, "pay_preview", None)
|
|
758
|
+
for e in entries
|
|
759
|
+
)
|
|
760
|
+
if show_pay:
|
|
761
|
+
table.add_column("Paid", width=16)
|
|
762
|
+
table.add_column("Notes", width=24 if show_pay else 40)
|
|
752
763
|
|
|
753
764
|
for e in entries:
|
|
754
765
|
start = e.start_date.isoformat() if e.start_date else "—"
|
|
@@ -763,12 +774,32 @@ def format_timeoff_table(
|
|
|
763
774
|
format_duration(hours),
|
|
764
775
|
_pto_kind_label(e),
|
|
765
776
|
_pto_status_label(e),
|
|
766
|
-
truncate(e.notes or "", 40),
|
|
767
777
|
])
|
|
778
|
+
if show_pay:
|
|
779
|
+
row.append(_pto_pay_label(e))
|
|
780
|
+
row.append(truncate(e.notes or "", 24 if show_pay else 40))
|
|
768
781
|
table.add_row(*row)
|
|
769
782
|
return table
|
|
770
783
|
|
|
771
784
|
|
|
785
|
+
def _pto_pay_label(entry: Any) -> str:
|
|
786
|
+
"""'3 paid · 2 unpaid' for approved entries, 'if approved: …' for pending."""
|
|
787
|
+
def label(paid, unpaid) -> str:
|
|
788
|
+
parts = []
|
|
789
|
+
if paid and Decimal(str(paid)) > 0:
|
|
790
|
+
parts.append(f"{paid} paid")
|
|
791
|
+
if unpaid and Decimal(str(unpaid)) > 0:
|
|
792
|
+
parts.append(f"{unpaid} unpaid")
|
|
793
|
+
return " · ".join(parts) or "unpaid"
|
|
794
|
+
|
|
795
|
+
if getattr(entry, "paid_days", None) is not None:
|
|
796
|
+
return label(entry.paid_days, entry.unpaid_days)
|
|
797
|
+
preview = getattr(entry, "pay_preview", None)
|
|
798
|
+
if preview:
|
|
799
|
+
return "if approved: " + label(preview.get("paid_days"), preview.get("unpaid_days"))
|
|
800
|
+
return ""
|
|
801
|
+
|
|
802
|
+
|
|
772
803
|
def format_timeoff_calendar(
|
|
773
804
|
items: list[TimeOffCalendarItem],
|
|
774
805
|
from_date: date,
|
|
@@ -282,11 +282,10 @@ class TimeOffEntryListItem(BaseModel):
|
|
|
282
282
|
days_count: int = 0
|
|
283
283
|
kind: str = "vacation"
|
|
284
284
|
kind_display: str = ""
|
|
285
|
-
#
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
resolved_is_paid: bool = True
|
|
285
|
+
# Paid/unpaid split (CRO-84) — only sent to those allowed to see it.
|
|
286
|
+
paid_days: Decimal | None = None
|
|
287
|
+
unpaid_days: Decimal | None = None
|
|
288
|
+
pay_preview: dict | None = None
|
|
290
289
|
status: str = "pending"
|
|
291
290
|
status_display: str = ""
|
|
292
291
|
resolved_hours_per_day: Decimal | None = None
|
|
@@ -312,8 +311,9 @@ class TimeOffEntry(BaseModel):
|
|
|
312
311
|
total_hours: Decimal | None = None
|
|
313
312
|
kind: str = "vacation"
|
|
314
313
|
kind_display: str = ""
|
|
315
|
-
|
|
316
|
-
|
|
314
|
+
paid_days: Decimal | None = None
|
|
315
|
+
unpaid_days: Decimal | None = None
|
|
316
|
+
pay_preview: dict | None = None
|
|
317
317
|
status: str = "pending"
|
|
318
318
|
status_display: str = ""
|
|
319
319
|
notes: str = ""
|
|
@@ -300,6 +300,8 @@ ct timesheet list # List your timesheets
|
|
|
300
300
|
ct timesheet list --status submitted # Filter by status
|
|
301
301
|
ct timesheet submit --from 2026-03-10 --to 2026-03-16 # Submit full week
|
|
302
302
|
ct timesheet submit --from 2026-03-30 --to 2026-03-31 # Partial week (end-of-month split)
|
|
303
|
+
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)
|
|
304
|
+
ct timesheet remind --user ana@acme.com --from 2026-03-10 --to 2026-03-16 # Email them a reminder to submit (project_manager+)
|
|
303
305
|
ct timesheet recall <id> # Recall submitted timesheet back to draft (--force to skip confirm)
|
|
304
306
|
ct timesheet approve <id> # Approve the whole timesheet (admin, direct manager, or PM of every project in it)
|
|
305
307
|
ct timesheet approve <id> --project <project-id> # Approve one project's portion (PM for that project)
|
|
@@ -424,7 +426,7 @@ ct log 0.25 --project argo --task BE --notes "Daily standup"
|
|
|
424
426
|
|
|
425
427
|
### Time Off (PTO)
|
|
426
428
|
|
|
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).
|
|
429
|
+
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
430
|
|
|
429
431
|
> **IMPORTANT**: Never use magic routing (`ct "..."`) or `ct ai parse` for PTO operations. Always construct explicit `ct pto ...` commands.
|
|
430
432
|
> Full reference: `references/pto.md` and `references/commands.md` § PTO.
|
|
@@ -432,7 +434,6 @@ Per-user time-off entries with a kind (vacation, sick, holiday, personal, other)
|
|
|
432
434
|
```bash
|
|
433
435
|
ct pto request <dates> # Submit your own PTO request (status=pending)
|
|
434
436
|
ct pto add <user> <dates> # Add PTO on behalf of user, auto-approved (project manager+, in scope)
|
|
435
|
-
ct pto add <user> <dates> --unpaid # ... marked unpaid (account manager+; 400 for anyone below, not ignored)
|
|
436
437
|
ct pto list [--mine|--team] # List PTO entries with optional filters
|
|
437
438
|
ct pto show <id> # Detail view of a single PTO entry (NEVER truncate the ID)
|
|
438
439
|
ct pto edit <id> [fields] # Edit date range, kind, notes, hours/day (your own: pending only)
|
|
@@ -472,8 +473,11 @@ ct payroll payments --period 2026-03 --status paid
|
|
|
472
473
|
|
|
473
474
|
**Workflow**: Configure compensation → Run liquidation → Approve → Mark paid.
|
|
474
475
|
|
|
475
|
-
### Team (Direct Reports)
|
|
476
|
+
### Team (Member Profiles and Direct Reports)
|
|
476
477
|
```bash
|
|
478
|
+
ct team show me # Your own profile, hours, utilization, timesheets, time off
|
|
479
|
+
ct team show <user-id> --from 2026-09-01 --to 2026-09-30 # A member's page; sections follow your access (see below)
|
|
480
|
+
ct team show alice@co.com # Email lookup needs project_manager+; others use the user ID
|
|
477
481
|
ct team list # All direct-report assignments (admin+) or your own
|
|
478
482
|
ct team list --manager alice@company.com # Filter to a specific manager
|
|
479
483
|
ct team reports # Show YOUR direct reports
|
|
@@ -488,6 +492,8 @@ ct team set alice@co.com # Clear all (zero --membe
|
|
|
488
492
|
ct team set alice@co.com --all # Set her reports to the full org
|
|
489
493
|
```
|
|
490
494
|
|
|
495
|
+
`ct team show` returns only what you may see: its `access.level` is `full` (yourself, your direct reports, or everyone for admins), `projects` (people on projects you run — their time on those projects only), or `profile` (profile only). Missing sections mean no access, not no data — don't report them as zero.
|
|
496
|
+
|
|
491
497
|
All mutating commands (`assign`, `unassign`, `set`) hit the atomic `bulk-set` endpoint — changes apply in a single transaction and the response reports `added` / `removed` / `skipped` buckets.
|
|
492
498
|
|
|
493
499
|
**Visibility rule** — what a `manager` or `project_manager` sees of other people:
|
|
@@ -1562,7 +1562,7 @@ Switches active organization. Saves to config `defaults.organization`.
|
|
|
1562
1562
|
ct org members [--json]
|
|
1563
1563
|
```
|
|
1564
1564
|
|
|
1565
|
-
Shows: Name, Email, Role, Projects, Status. Requires manager
|
|
1565
|
+
Shows: Name, Email, Role, Projects, Status. Requires project_manager+. Account managers and up see every member; a project manager sees the people they reach (themselves, their direct reports, the people on projects they run), and of each person only the projects they share with them — rates are null below manager. Each member carries `projects[]` in JSON — `{id, name, code, status, project_role}` for every project they are on; `project_role` is `"project_manager"` when they run it (shown as "(PM)" in the table). Endpoint: `GET /members/`
|
|
1566
1566
|
|
|
1567
1567
|
### ct org invite
|
|
1568
1568
|
|
|
@@ -1735,13 +1735,14 @@ Lists your timesheets, plus — for managers and project managers — the timesh
|
|
|
1735
1735
|
### ct timesheet submit
|
|
1736
1736
|
|
|
1737
1737
|
```
|
|
1738
|
-
ct timesheet submit --from DATE --to DATE [--force/-f] [--json]
|
|
1738
|
+
ct timesheet submit --from DATE --to DATE [--user/-u USER] [--force/-f] [--json]
|
|
1739
1739
|
```
|
|
1740
1740
|
|
|
1741
1741
|
| Option | Type | Description |
|
|
1742
1742
|
|--------|------|-------------|
|
|
1743
1743
|
| `--from` | string (required) | Period start date |
|
|
1744
1744
|
| `--to` | string (required) | Period end date |
|
|
1745
|
+
| `--user`, `-u` | string | Submit on someone's behalf (email or UUID) |
|
|
1745
1746
|
| `--force`, `-f` | flag | Skip confirmation prompt |
|
|
1746
1747
|
| `--json` | flag | JSON output |
|
|
1747
1748
|
|
|
@@ -1749,6 +1750,23 @@ Submits a timesheet for the given period (any date range — not limited to full
|
|
|
1749
1750
|
|
|
1750
1751
|
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
1752
|
|
|
1753
|
+
`--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`.
|
|
1754
|
+
|
|
1755
|
+
### ct timesheet remind
|
|
1756
|
+
|
|
1757
|
+
```
|
|
1758
|
+
ct timesheet remind --user USER --from DATE --to DATE [--json]
|
|
1759
|
+
```
|
|
1760
|
+
|
|
1761
|
+
| Option | Type | Description |
|
|
1762
|
+
|--------|------|-------------|
|
|
1763
|
+
| `--user`, `-u` | string (required) | Who to remind (email or UUID) |
|
|
1764
|
+
| `--from` | string (required) | Period start date |
|
|
1765
|
+
| `--to` | string (required) | Period end date |
|
|
1766
|
+
| `--json` | flag | JSON output |
|
|
1767
|
+
|
|
1768
|
+
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`.
|
|
1769
|
+
|
|
1752
1770
|
### ct timesheet approve
|
|
1753
1771
|
|
|
1754
1772
|
```
|
|
@@ -2586,6 +2604,29 @@ Endpoint: `GET /insights/profitability/`
|
|
|
2586
2604
|
|
|
2587
2605
|
Manage manager → direct-report assignments. Mirrors Harvest's manager model: assign people to a manager so the manager can see their hours across any project without being a PM on each project individually. Orthogonal to per-project PM assignments; visibility is the union of both.
|
|
2588
2606
|
|
|
2607
|
+
### ct team show
|
|
2608
|
+
|
|
2609
|
+
```
|
|
2610
|
+
ct team show MEMBER [--from DATE] [--to DATE] [--json]
|
|
2611
|
+
```
|
|
2612
|
+
|
|
2613
|
+
One member's profile and activity — the CLI view of the web member page. `MEMBER` is `me`, a user ID, or an email; email lookup reads the member list (project_manager+, within their reach), so members and viewers pass `me` or the user ID. Defaults to the last 30 days; the range can be at most 366 days.
|
|
2614
|
+
|
|
2615
|
+
What comes back depends on who is asking, and sections the caller may not see are **left out** of the response, not nulled. `access.level` says which case applies:
|
|
2616
|
+
- `full` — themselves, their people manager (direct reports via `ct team`), admins/owners. Hours, billable %, weekly trend, breakdown by project/client/task, recent entries, running timer, plus utilization against capacity and the last 8 timesheet periods (`missing` = never submitted).
|
|
2617
|
+
- `projects` — someone who runs (explicit `project_role=project_manager`) a project the person works on. The same time sections, limited to the caller's projects; no utilization or timesheets.
|
|
2618
|
+
- `profile` — anyone else who can reach the person (account managers see the whole roster). Name, role, capacity, joined date, and projects (below manager: only projects shared with the caller).
|
|
2619
|
+
|
|
2620
|
+
Every level includes `profile.projects[]`: the projects the person is assigned to, each with `effective_role` — what the assignment grants on that project (`project_manager`, `member`, `viewer`; an org-level project manager without an explicit project role is a plain `member` there). Below manager, only projects shared with the caller are listed.
|
|
2621
|
+
|
|
2622
|
+
`time_off` (upcoming, recent, approved days by kind this year) is included for the person themselves and for org-level people managers who reach them. Rates and billable amounts are manager+ and never shown on your own profile; pay is not part of this view — use `ct payroll`. A person outside your reach answers `404`.
|
|
2623
|
+
|
|
2624
|
+
**Options:**
|
|
2625
|
+
- `--from DATE` / `--to DATE` — period (YYYY-MM-DD)
|
|
2626
|
+
- `--json` — `{"profile": ..., "overview": ...}`
|
|
2627
|
+
|
|
2628
|
+
Endpoints: `GET /members/<user_id>/`, `GET /members/<user_id>/overview/?from=&to=`
|
|
2629
|
+
|
|
2589
2630
|
### ct team list
|
|
2590
2631
|
|
|
2591
2632
|
```
|
|
@@ -2717,7 +2758,7 @@ Submit a PTO request for yourself. Created with `status=pending` — must be app
|
|
|
2717
2758
|
| Argument/Option | Type | Required | Description |
|
|
2718
2759
|
|-----------------|------|----------|-------------|
|
|
2719
2760
|
| `<dates>` | string | yes | `YYYY-MM-DD` or `YYYY-MM-DD:YYYY-MM-DD` |
|
|
2720
|
-
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
|
|
2761
|
+
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `birthday` (one day, once per calendar year), `other` |
|
|
2721
2762
|
| `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default (see `references/pto.md`) |
|
|
2722
2763
|
| `--notes`, `-n` | string | no | Reason or notes |
|
|
2723
2764
|
| `--json` | flag | no | JSON output |
|
|
@@ -2763,10 +2804,9 @@ Add a PTO entry on behalf of another user (project manager and above). The entry
|
|
|
2763
2804
|
|-----------------|------|----------|-------------|
|
|
2764
2805
|
| `<user>` | string | yes | User email or UUID |
|
|
2765
2806
|
| `<dates>` | string | yes | `YYYY-MM-DD` or `YYYY-MM-DD:YYYY-MM-DD` |
|
|
2766
|
-
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `other` |
|
|
2807
|
+
| `--kind`, `-k` | string | no | `vacation` (default), `sick`, `holiday`, `personal`, `birthday` (one day, once per calendar year), `other` |
|
|
2767
2808
|
| `--hours-per-day`, `-h` | decimal | no | Hours/day. Empty = capacity-based default |
|
|
2768
2809
|
| `--notes`, `-n` | string | no | Reason or context notes |
|
|
2769
|
-
| `--paid` / `--unpaid` | flag | no | Whether the hours are paid. **Account manager+ only** — sending it as a project manager returns `400`, it is NOT ignored. Omit to follow the org policy for the kind |
|
|
2770
2810
|
| `--json` | flag | no | JSON output |
|
|
2771
2811
|
|
|
2772
2812
|
**Response (201):** full entry serializer with `status=approved`, `decided_by=<your user>`, `decided_at=<now>`.
|
|
@@ -2777,8 +2817,6 @@ Add a PTO entry on behalf of another user (project manager and above). The entry
|
|
|
2777
2817
|
- `400` on the `user` field — caller is below `project_manager`, or the named
|
|
2778
2818
|
user is outside the caller's visibility scope. This is a `400`, not a `403`:
|
|
2779
2819
|
the check lives in the serializer, so treat it as a validation error.
|
|
2780
|
-
- `400` on the `is_paid` field — caller is below account manager and sent
|
|
2781
|
-
`--paid`/`--unpaid`.
|
|
2782
2820
|
|
|
2783
2821
|
**Usage examples:**
|
|
2784
2822
|
|
{crowdtime_cli-0.19.0 → crowdtime_cli-0.21.0}/src/crowdtime_cli/skills/crowdtime/references/pto.md
RENAMED
|
@@ -25,31 +25,13 @@ 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**.
|
|
31
32
|
|
|
32
33
|
---
|
|
33
34
|
|
|
34
|
-
## Paid vs unpaid
|
|
35
|
-
|
|
36
|
-
Every entry resolves to paid or unpaid, exposed as `resolved_is_paid`:
|
|
37
|
-
|
|
38
|
-
1. The entry's own `is_paid`, when set.
|
|
39
|
-
2. Otherwise the organization's policy for that kind (`time_off_paid_kinds`).
|
|
40
|
-
3. Otherwise paid.
|
|
41
|
-
|
|
42
|
-
**Paid** hours count towards the hours a person is paid for that month. **Unpaid**
|
|
43
|
-
hours still explain the absence — reports and anomaly checks treat the person as
|
|
44
|
-
away either way — but do not count towards what they are paid.
|
|
45
|
-
|
|
46
|
-
Only account managers and above can set `is_paid`, via `ct pto add --paid/--unpaid`. A project manager passing the flag gets a `400` on the `is_paid` field — the request is refused, not silently booked under the org default. Omit the flag and the organization's policy for the kind applies. The same holds on update: changing `is_paid` on an existing entry is refused (`400`) below account manager.
|
|
47
|
-
A member submitting their own request never chooses; it follows the org policy.
|
|
48
|
-
Do not tell a user their leave is paid because they asked for it — read
|
|
49
|
-
`resolved_is_paid` back from the entry.
|
|
50
|
-
|
|
51
|
-
---
|
|
52
|
-
|
|
53
35
|
## Status lifecycle
|
|
54
36
|
|
|
55
37
|
```
|
|
@@ -444,6 +444,9 @@ ct report team --week
|
|
|
444
444
|
# Specific member
|
|
445
445
|
ct report team --month --member john@company.com
|
|
446
446
|
|
|
447
|
+
# One person in depth: hours trend, projects, timesheet history, time off
|
|
448
|
+
ct team show john@company.com --from 2026-09-01 --to 2026-09-30
|
|
449
|
+
|
|
447
450
|
# List all members
|
|
448
451
|
ct org members
|
|
449
452
|
```
|
|
@@ -631,6 +634,11 @@ ct timesheet reject <timesheet-id> --notes "Missing entries for Wednesday — pl
|
|
|
631
634
|
# Reject multiple timesheets at once
|
|
632
635
|
ct timesheet bulk-reject <id1> <id2> --notes "Incomplete entries for the week"
|
|
633
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
|
+
|
|
634
642
|
# Approved it too early? Reopen — back to draft, entries unlocked, user fixes & resubmits
|
|
635
643
|
ct timesheet reopen <timesheet-id> --notes "Approved before Friday's entries were added"
|
|
636
644
|
ct timesheet reopen <timesheet-id> --notes "Wrong project on Tuesday" --force
|
|
@@ -1474,7 +1482,7 @@ Use this when a member wants to request time off. The request goes into `pending
|
|
|
1474
1482
|
|
|
1475
1483
|
**Before running any command, ask the user:**
|
|
1476
1484
|
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.
|
|
1477
|
-
2. What kind? (vacation / sick / holiday / personal / other) — if not specified, default to `vacation` and confirm.
|
|
1485
|
+
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.
|
|
1478
1486
|
3. Hours per day? — leave empty unless they want partial days (e.g. half-day sick leave).
|
|
1479
1487
|
4. Notes? — optional but useful for the manager.
|
|
1480
1488
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|